diff --git a/.changes/3.102.json b/.changes/3.102.json new file mode 100644 index 00000000000..39d92c1e9bd --- /dev/null +++ b/.changes/3.102.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-01-15", + "version" : "3.102", + "entries" : [ ] +} \ No newline at end of file diff --git a/.changes/3.103.json b/.changes/3.103.json new file mode 100644 index 00000000000..f9899ac44e9 --- /dev/null +++ b/.changes/3.103.json @@ -0,0 +1,17 @@ +{ + "date" : "2026-02-12", + "version" : "3.103", + "entries" : [ { + "type" : "bugfix", + "description" : "Amazon Q Chat: Fix chat history restoration to display rich UI elements and persist user preferences" + }, { + "type" : "bugfix", + "description" : "Amazon Q: Fix LSP server startup failure when Node.js runtime path is configured as a directory instead of the executable path" + }, { + "type" : "bugfix", + "description" : "Amazon Q: Fix race condition causing \"Can't remove document listener\" error during LSP restart" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for JetBrains Gateway version 2025.2 and for IDEs based on the 2024.3 platform" + } ] +} \ No newline at end of file diff --git a/.changes/3.104.json b/.changes/3.104.json new file mode 100644 index 00000000000..b4af90336c6 --- /dev/null +++ b/.changes/3.104.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-02-27", + "version" : "3.104", + "entries" : [ ] +} \ No newline at end of file diff --git a/.changes/3.105.json b/.changes/3.105.json new file mode 100644 index 00000000000..9cbbd8d20f2 --- /dev/null +++ b/.changes/3.105.json @@ -0,0 +1,8 @@ +{ + "date" : "2026-03-06", + "version" : "3.105", + "entries" : [ { + "type" : "removal", + "description" : "Removed support for 2024.3.x IDEs and Gateway 2025.2" + } ] +} \ No newline at end of file diff --git a/.changes/3.106.json b/.changes/3.106.json new file mode 100644 index 00000000000..c5dcc924b98 --- /dev/null +++ b/.changes/3.106.json @@ -0,0 +1,14 @@ +{ + "date" : "2026-03-13", + "version" : "3.106", + "entries" : [ { + "type" : "feature", + "description" : "Added support for validating and deploying CloudFormation templates to new or existing stacks" + }, { + "type" : "feature", + "description" : "Added intelligent authoring support for CloudFormation templates." + }, { + "type" : "feature", + "description" : "Added support for viewing CloudFormation stack details such as resources and events" + } ] +} \ No newline at end of file diff --git a/.changes/3.107.json b/.changes/3.107.json new file mode 100644 index 00000000000..aa08efa4aa3 --- /dev/null +++ b/.changes/3.107.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-03-18", + "version" : "3.107", + "entries" : [ ] +} \ No newline at end of file diff --git a/.changes/3.108.json b/.changes/3.108.json new file mode 100644 index 00000000000..e66a2704d19 --- /dev/null +++ b/.changes/3.108.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-03-26", + "version" : "3.108", + "entries" : [ ] +} \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bddc4d68dfa..e868b69ebfb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ -* @aws/aws-ides-team +* @aws/aws-toolkits-team chat/ @aws/codewhisperer-team codemodernizer/ @aws/elastic-gumby diff --git a/.github/workflows/cfn-lsp-integ.yml b/.github/workflows/cfn-lsp-integ.yml new file mode 100644 index 00000000000..30e08bd5945 --- /dev/null +++ b/.github/workflows/cfn-lsp-integ.yml @@ -0,0 +1,36 @@ +name: CloudFormation LSP Integration Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main, feature/* ] + paths: + - '.github/workflows/cfn-lsp-integ.yml' + - 'plugins/toolkit/jetbrains-core/src*/**/cfnlsp/**' + - 'plugins/toolkit/jetbrains-core/it-253+/**/cfnlsp/**' + +jobs: + build: + name: ${{ matrix.os }} + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Support longpaths + if: ${{ matrix.os == 'windows-latest' }} + run: git config --system core.longpaths true + - uses: actions/checkout@v2 + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '21' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Run CloudFormation LSP Integration Tests + run: ./gradlew :plugin-toolkit:jetbrains-core:integrationTest --info --full-stacktrace --console plain --tests "software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspIntegrationTest" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 32bd1956813..1af665757ae 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: build_target: [ ':plugin-core:buildPlugin', ':plugin-toolkit:intellij-standalone:buildPlugin', ':plugin-amazonq:buildPlugin' ] - version: [ '2024.3', '2025.1', '2025.2', '2025.3' ] + version: [ '2025.1', '2025.2', '2025.3' ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.run/Run AWS Toolkit - Community [2024.3].run.xml b/.run/Run AWS Toolkit - Community [2024.3].run.xml deleted file mode 100644 index ffe9b21a4b3..00000000000 --- a/.run/Run AWS Toolkit - Community [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Gateway [2025.2].run.xml b/.run/Run AWS Toolkit - Gateway [2025.2].run.xml deleted file mode 100644 index 173f374a06b..00000000000 --- a/.run/Run AWS Toolkit - Gateway [2025.2].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Rider [2024.3].run.xml b/.run/Run AWS Toolkit - Rider [2024.3].run.xml deleted file mode 100644 index 36f24db1b98..00000000000 --- a/.run/Run AWS Toolkit - Rider [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Ultimate [2024.3].run.xml b/.run/Run AWS Toolkit - Ultimate [2024.3].run.xml deleted file mode 100644 index bbb485b5902..00000000000 --- a/.run/Run AWS Toolkit - Ultimate [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Community [2024.3].run.xml b/.run/Run All - Community [2024.3].run.xml deleted file mode 100644 index 988ece48c18..00000000000 --- a/.run/Run All - Community [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Rider [2024.3].run.xml b/.run/Run All - Rider [2024.3].run.xml deleted file mode 100644 index ba5df9f5ab5..00000000000 --- a/.run/Run All - Rider [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Ultimate [2024.3].run.xml b/.run/Run All - Ultimate [2024.3].run.xml deleted file mode 100644 index 49f02c1a438..00000000000 --- a/.run/Run All - Ultimate [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Community [2024.3].run.xml b/.run/Run Amazon Q - Community [2024.3].run.xml deleted file mode 100644 index 4f6b86d32f1..00000000000 --- a/.run/Run Amazon Q - Community [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Rider [2024.3].run.xml b/.run/Run Amazon Q - Rider [2024.3].run.xml deleted file mode 100644 index 9e64b94c3e3..00000000000 --- a/.run/Run Amazon Q - Rider [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Ultimate [2024.3].run.xml b/.run/Run Amazon Q - Ultimate [2024.3].run.xml deleted file mode 100644 index 6891367056b..00000000000 --- a/.run/Run Amazon Q - Ultimate [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/generateConfigs.py b/.run/generateConfigs.py index 9cf67342b79..f21d389662e 100644 --- a/.run/generateConfigs.py +++ b/.run/generateConfigs.py @@ -52,7 +52,7 @@ def write_config(mv: str, ide: IdeVariant, plugin: PluginVariant): f.write(TEMPLATE.format(plugin = plugin, variant = ide, major_version = mv)) if __name__ == '__main__': - mvs = ["2024.3", "2025.1", "2025.2", "2025.3"] + mvs = ["2025.1", "2025.2", "2025.3"] ides = [ IdeVariant("Community", "IC"), IdeVariant("Rider", "RD"), diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5760b5604..22c87b34bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +# _3.108_ (2026-03-26) + +# _3.107_ (2026-03-18) + +# _3.106_ (2026-03-13) +- **(Feature)** Added support for validating and deploying CloudFormation templates to new or existing stacks +- **(Feature)** Added intelligent authoring support for CloudFormation templates. +- **(Feature)** Added support for viewing CloudFormation stack details such as resources and events + +# _3.105_ (2026-03-06) +- **(Removal)** Removed support for 2024.3.x IDEs and Gateway 2025.2 + +# _3.104_ (2026-02-27) + +# _3.103_ (2026-02-12) +- **(Bug Fix)** Amazon Q Chat: Fix chat history restoration to display rich UI elements and persist user preferences +- **(Bug Fix)** Amazon Q: Fix LSP server startup failure when Node.js runtime path is configured as a directory instead of the executable path +- **(Bug Fix)** Amazon Q: Fix race condition causing "Can't remove document listener" error during LSP restart +- **(Deprecation)** An upcoming release will remove support for JetBrains Gateway version 2025.2 and for IDEs based on the 2024.3 platform + +# _3.102_ (2026-01-15) + # _3.101_ (2026-01-09) - **(Bug Fix)** fix: automatically clear customization if chat receives access denied on the customization - **(Bug Fix)** Amazon Q: fix issue where changing the customization does not apply to chat until project re-open diff --git a/README.md b/README.md index 6ec8b4eb939..6cc2da25f59 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,28 @@ resource types supported by the plugin. * **Authentication** - Connect to AWS using static credentials, credential process, AWS Builder ID or AWS SSO. [Learn more about authentication options](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/credentials) +### ![CloudFormation][cloudformation-icon] AWS CloudFormation + +Powered by the [CloudFormation Language Server](https://github.com/aws-cloudformation/cloudformation-languageserver), the toolkit provides rich CloudFormation +template authoring, stack and resource management capabilities. + +**Template Authoring** +* **Language Support** - Syntax validation, auto-completion, hover documentation, and go-to-definition for CloudFormation YAML/JSON templates +* **Static Validation** - Real-time diagnostics and error highlighting powered by [cfn-lint](https://github.com/aws-cloudformation/cfn-lint) and [CloudFormation Guard](https://github.com/aws-cloudformation/cloudformation-guard) + +**Stack Management** +* **CloudFormation Explorer** - Browse stacks, resources, and change sets in a dedicated tool window +* **Stack View** - Inspect stack details across Overview, Resources, Events, and Outputs tabs +* **Resource Explorer** - Search, add, and browse live resource types and their properties + +**Validate and Deploy** +* **Early Validation** - Create a change set to validate template for deployment readiness and receive diagnostics +* **Change Set Diff View** - Review resource-level and property-level changes before deploying, with side-by-side JSON diff +* **Live-Aware Drift Detection** - Inline drift annotations highlight resources that have changed outside of CloudFormation +* **Execute and Delete Change Sets** - Deploy or clean up change sets directly from the diff view or explorer tree + ### Services -#### ![CloudFormation][cloudformation-icon] AWS CloudFormation -* View events, resources, and outputs for your CloudFormation stacks #### ![CloudWatch Logs][cloudwatch-logs-icon] CloudWatch Logs * View and search your CloudWatch log streams #### ![AWS Lambda][lambda-icon] AWS Lambda diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt index 30d3187286c..96c016a286d 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt @@ -28,7 +28,6 @@ fun Project.jvmTarget(): Provider = withCurrentProfileName { // https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#stdlib-miscellaneous fun Project.kotlinTarget(): Provider = withCurrentProfileName { when (it) { - "2024.3" -> KotlinVersionEnum.KOTLIN_2_0 "2025.1", "2025.2", "2025.3" -> KotlinVersionEnum.KOTLIN_2_1 else -> error("not set") }.version diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt index 84d33ba8aae..b0930bc93ab 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt @@ -26,45 +26,6 @@ object IdeVersions { ) private val ideProfiles = listOf( - Profile( - name = "2024.3", - community = ProductProfile( - sdkVersion = "2024.3", - bundledPlugins = commonPlugins + listOf( - "com.intellij.java", - "com.intellij.gradle", - "org.jetbrains.idea.maven", - ), - marketplacePlugins = listOf( - "org.toml.lang:243.21565.122", - "PythonCore:243.21565.211", - "Docker:243.21565.204", - "com.intellij.modules.json:243.26574.91" - ) - ), - ultimate = ProductProfile( - sdkVersion = "2024.3", - bundledPlugins = commonPlugins + listOf( - "JavaScript", - "JavaScriptDebugger", - "com.intellij.database", - "com.jetbrains.codeWithMe", - ), - marketplacePlugins = listOf( - "org.toml.lang:243.21565.122", - "Pythonid:243.21565.211", - "org.jetbrains.plugins.go:243.21565.211", - "com.intellij.modules.json:243.26574.91" - ) - ), - rider = RiderProfile( - sdkVersion = "2024.3", - bundledPlugins = commonPlugins, - netFrameworkTarget = "net472", - rdGenVersion = "2024.3.0", - nugetVersion = " 2024.3.0" - ) - ), Profile( name = "2025.1", community = ProductProfile( @@ -109,10 +70,6 @@ object IdeVersions { ), Profile( name = "2025.2", - gateway = ProductProfile( - sdkVersion = "2025.2", - bundledPlugins = listOf("org.jetbrains.plugins.terminal") - ), community = ProductProfile( sdkVersion = "2025.2", bundledPlugins = commonPlugins + listOf( @@ -153,11 +110,11 @@ object IdeVersions { Profile( name = "2025.3", gateway = ProductProfile( - sdkVersion = "253.28086.53", + sdkVersion = "2025.3", bundledPlugins = listOf("org.jetbrains.plugins.terminal") ), community = ProductProfile( - sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + sdkVersion = "2025.3", bundledPlugins = commonPlugins + listOf( "com.intellij.java", "com.intellij.gradle", @@ -165,14 +122,14 @@ object IdeVersions { "com.intellij.properties" ), marketplacePlugins = listOf( - "org.toml.lang:253.28294.86", - "PythonCore:253.28294.51", - "Docker:253.28294.90", - "com.intellij.modules.json:253.28294.51" + "org.toml.lang:253.28294.334", + "PythonCore:253.29346.138", + "Docker:253.29346.125", + "com.intellij.modules.json:253.28294.251" ) ), ultimate = ProductProfile( - sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + sdkVersion = "2025.3", bundledPlugins = commonPlugins + listOf( "JavaScript", "JavaScriptDebugger", @@ -180,9 +137,9 @@ object IdeVersions { "com.jetbrains.codeWithMe" ), marketplacePlugins = listOf( - "Pythonid:253.28294.51", - "org.jetbrains.plugins.go:253.28294.51", - "com.intellij.modules.json:253.28294.51" + "Pythonid:253.29346.138", + "org.jetbrains.plugins.go:253.29346.50", + "com.intellij.modules.json:253.28294.251" ) ), rider = RiderProfile( diff --git a/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts b/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts index 4ada9845271..9acd873a38a 100644 --- a/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts @@ -19,7 +19,7 @@ dependencies { // detekt with type introspection configured in kotlin conventions private val detektFiles = fileTree(projectDir).matching { include("**/*.kt", "**/*.kts") - exclude("**/build") + exclude("**/build", "**/bin") } detekt { diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts index 720e9913b72..44cb90e0d47 100644 --- a/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts @@ -6,22 +6,22 @@ import software.aws.toolkits.gradle.intellij.IdeFlavor import software.aws.toolkits.gradle.intellij.ToolkitIntelliJExtension val intellijToolkit = project.extensions.create("intellijToolkit", ToolkitIntelliJExtension::class) -// TODO: how did this break? +// Use convention() so that toolkit-publish-root-conventions can override with runIdeVariant when { project.name.contains("jetbrains-rider") -> { - intellijToolkit.ideFlavor.set(IdeFlavor.RD) + intellijToolkit.ideFlavor.convention(IdeFlavor.RD) } project.name.contains("jetbrains-ultimate") -> { - intellijToolkit.ideFlavor.set(IdeFlavor.IU) + intellijToolkit.ideFlavor.convention(IdeFlavor.IU) } project.name.contains("jetbrains-gateway") -> { - intellijToolkit.ideFlavor.set(IdeFlavor.GW) + intellijToolkit.ideFlavor.convention(IdeFlavor.GW) } else -> { - intellijToolkit.ideFlavor.set(IdeFlavor.IC) + intellijToolkit.ideFlavor.convention(IdeFlavor.IC) } } diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts index a832242651d..d91e6a1445f 100644 --- a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.tasks.PrepareSandboxTask import software.aws.toolkits.gradle.findFolders +import software.aws.toolkits.gradle.intellij.IdeFlavor import software.aws.toolkits.gradle.intellij.IdeVersions import software.aws.toolkits.gradle.intellij.toolkitIntelliJ @@ -64,6 +65,12 @@ configurations { useVersion(versionCatalog.findVersion("kotlin").get().toString()) because("resolve kotlin version conflicts in favor of local version catalog") } + + // https://nvd.nist.gov/vuln/detail/cve-2022-25647 + if (requested.group == "com.google.code.gson" && requested.name == "gson") { + useVersion("2.11.0") + because("CVE-2022-25647 requires Gson >= 2.8.9") + } } } } @@ -91,7 +98,14 @@ dependencies { // annoying resolution issue that we don't want to bother fixing if (!project.name.contains("jetbrains-gateway")) { - val type = toolkitIntelliJ.ideFlavor.map { IntelliJPlatformType.fromCode(it.toString()) } + val type = toolkitIntelliJ.ideFlavor.map { flavor -> + // Starting with 2025.3, IntelliJ IDEA is unified (no separate Community edition) + if (version.get().startsWith("2025.3") && flavor == IdeFlavor.IC) { + IntelliJPlatformType.IntellijIdeaUltimate + } else { + IntelliJPlatformType.fromCode(flavor.toString()) + } + } create(type, version, useInstaller = false) } else { diff --git a/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts b/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts index d7b079b1160..8aed71ca141 100644 --- a/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts @@ -24,8 +24,14 @@ intellijPlatform { pluginVerification { ides { // recommended() appears to resolve latest EAP for a product? - ide(provider { IntelliJPlatformType.IntellijIdeaCommunity }, toolkitIntelliJ.version()) - ide(provider { IntelliJPlatformType.IntellijIdeaUltimate }, toolkitIntelliJ.version()) + // Starting with 2025.3, IntelliJ IDEA is unified (no separate Community edition) + val version = toolkitIntelliJ.version().get() + if (version.startsWith("2025.3")) { + ide(provider { IntelliJPlatformType.IntellijIdeaUltimate }, toolkitIntelliJ.version()) + } else { + ide(provider { IntelliJPlatformType.IntellijIdeaCommunity }, toolkitIntelliJ.version()) + ide(provider { IntelliJPlatformType.IntellijIdeaUltimate }, toolkitIntelliJ.version()) + } } } } @@ -49,7 +55,12 @@ dependencies { // prefer versions declared in IdeVersions toolkitIntelliJ.apply { - ideFlavor.convention(IdeFlavor.values().firstOrNull { it.name == runIdeVariant.orNull } ?: IdeFlavor.IC) + val defaultFlavor = if (version().get().startsWith("2025.3")) { + IdeFlavor.IU // Use unified IntelliJ IDEA for 2025.3+ + } else { + IdeFlavor.IC // Use Community for older versions + } + ideFlavor.set(IdeFlavor.values().firstOrNull { it.name == runIdeVariant.orNull } ?: defaultFlavor) } val (type, version) = if (runIdeVariant.isPresent) { val type = toolkitIntelliJ.ideFlavor.map { IntelliJPlatformType.fromCode(it.toString()) } @@ -57,7 +68,12 @@ dependencies { type to version } else { - provider { IntelliJPlatformType.IntellijIdeaCommunity } to toolkitIntelliJ.version() + val defaultType = if (toolkitIntelliJ.version().get().startsWith("2025.3")) { + provider { IntelliJPlatformType.IntellijIdeaUltimate } + } else { + provider { IntelliJPlatformType.IntellijIdeaCommunity } + } + defaultType to toolkitIntelliJ.version() } create(type, version, useInstaller = false) diff --git a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt index 8c083a40637..6692b8c7e6c 100644 --- a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt +++ b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt @@ -87,16 +87,6 @@ class BannedImportsRule : Rule() { ) ) } - - if (importedFqName == "com.google.gson.Gson") { - report( - CodeSmell( - issue, - Entity.from(element), - message = "Use jacksonObjectMapper() insted of Gson" - ) - ) - } } } } diff --git a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt index b16f3af3513..e83d9f74802 100644 --- a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt +++ b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt @@ -24,13 +24,6 @@ class BannedImportsRuleTest { .matches { it.id == "BannedImports" && it.message == "Use AssertJ instead of Hamcrest assertions" } } - @Test - fun `Importing Gson fails`() { - assertThat(rule.lint("import com.google.gson.Gson")) - .singleElement() - .matches { it.id == "BannedImports" && it.message == "Use jacksonObjectMapper() insted of Gson" } - } - @Test fun `Importing Kotlin test assert fails`() { assertThat(rule.lint("import kotlin.test.assertTrue")) diff --git a/gradle.properties b/gradle.properties index f60f58f6647..95111fc33c9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,13 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.102-SNAPSHOT +toolkitVersion=3.109-SNAPSHOT # Publish Settings publishToken= publishChannel= -ideProfileName=2025.2 +ideProfileName=2025.3 remoteRobotPort=8080 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2a32f799b5..345b1a82036 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,8 +23,8 @@ kotlin = "2.2.0" # set in /settings.gradle.kts kotlinCoroutines = "1.10.1" lsp4j = "0.24.0" -mockito = "5.12.0" -mockitoKotlin = "5.4.1-SNAPSHOT" +mockito = "5.20.0" +mockitoKotlin = "5.4.0" mockk = "1.13.17" nimbus-jose-jwt = "9.40" node-gradle = "7.0.2" diff --git a/kotlinResolution.settings.gradle.kts b/kotlinResolution.settings.gradle.kts index 3023d2002d2..04c5efe2227 100644 --- a/kotlinResolution.settings.gradle.kts +++ b/kotlinResolution.settings.gradle.kts @@ -6,7 +6,7 @@ dependencyResolutionManagement { maybeCreate("libs").apply { // pull value from IJ library list: https://github.com/JetBrains/intellij-community/blob//.idea/libraries/kotlinx_coroutines_core.xml val version = when (providers.gradleProperty("ideProfileName").getOrNull() ?: return@apply) { - "2024.3", "2025.1" -> { + "2025.1" -> { "1.8.0-intellij-11" } diff --git a/plugins/amazonq/build.gradle.kts b/plugins/amazonq/build.gradle.kts index c312b50dcdc..11b91c2c838 100644 --- a/plugins/amazonq/build.gradle.kts +++ b/plugins/amazonq/build.gradle.kts @@ -126,6 +126,15 @@ val prepareBundledFlare by tasks.registering(Copy::class) { from(zipTree(it)) { include("*.js") include("*.txt") + // Include stripped indexing folder for @file, @folder, @code support + include("indexing/lspServer.js") + include("indexing/dist/extension.js") + include("indexing/dist/tree-sitter.wasm") + include("indexing/dist/tree-sitter-wasms/**") + // Exclude heavy platform-specific native binaries and models + exclude("indexing/dist/bin/**") + exclude("indexing/dist/build/**") + exclude("indexing/models/**") } } } diff --git a/plugins/amazonq/chat/jetbrains-community/build.gradle.kts b/plugins/amazonq/chat/jetbrains-community/build.gradle.kts index 147d481b482..ef387294279 100644 --- a/plugins/amazonq/chat/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/chat/jetbrains-community/build.gradle.kts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -14,6 +15,12 @@ intellijToolkit { dependencies { intellijPlatform { localPlugin(project(":plugin-core")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } implementation(project(":plugin-amazonq:shared:jetbrains-community")) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt index 5df972bba09..ef82dec3b58 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt @@ -11,8 +11,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindowManager import kotlinx.coroutines.CoroutineScope -import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction -import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteractionType import software.aws.toolkits.jetbrains.services.amazonqCodeScan.runCodeScanMessage @Service(Service.Level.PROJECT) @@ -40,10 +38,6 @@ class AmazonQToolWindow private constructor( fun getStarted(project: Project) { // Make sure the window is shown showChatWindow(project) - - // Send the interaction message - val window = getInstance(project) - window.chatPanel.sendMessage(OnboardingPageInteraction(OnboardingPageInteractionType.CwcButtonClick), "cwc") } fun openScanTab(project: Project) { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt index 44ffbca8a32..4b80f8aa208 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt @@ -390,7 +390,7 @@ class BrowserConnector( } CHAT_LINK_CLICK -> { - handleChat(AmazonQChatServer.linkClick, node) + node.get("params")?.get("link")?.asText()?.let { BrowserUtil.browse(it) } } CHAT_INFO_LINK_CLICK -> { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt index a845e3c19f3..53874353ef2 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt @@ -91,7 +91,7 @@ class EditorThemeAdapter { defaultText = text, inactiveText = themeColor("TextField.inactiveForeground", default = 0x8C8C8C, darkDefault = 0x808080), - linkText = themeColor("link.foreground", "link", "Link.activeForeground", default = 0x589DF6), + linkText = themeColor("link", "Link.activeForeground", default = 0x589DF6), background = chatBackground, border = getBorderColor(currentScheme), diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt index fd93cc89c12..63d57530e4a 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt @@ -59,9 +59,11 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { override fun setup() { super.setup() - // Allow Python paths on Windows for test environment (Python plugin scans for interpreters) + // Allow Python paths for test environment (Python plugin scans for interpreters) if (SystemInfo.isWindows) { VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } else { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin") } amazonQStreamingClient = AmazonQStreamingClient.getInstance(projectRule.project) @@ -245,9 +247,12 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { companion object { @JvmStatic @BeforeClass - fun allowWindowsPythonPaths() { + fun allowPythonPaths() { + // Allow Python paths for test environment (Python plugin scans for interpreters) if (SystemInfo.isWindows) { VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "C:/Program Files") + } else { + VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "/usr/bin", "/usr/local/bin") } } diff --git a/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts b/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts index cb64e7d4c9d..12751759f01 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -14,6 +15,12 @@ intellijToolkit { dependencies { intellijPlatform { localPlugin(project(":plugin-core")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } implementation(project(":plugin-amazonq:shared:jetbrains-community")) diff --git a/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt b/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt index 6ba37aa6b9f..35e914bdb04 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt @@ -246,6 +246,13 @@ open class CodeWhispererCodeModernizerTestBase( @Before open fun setup() { + // Allow Python paths for test environment (Python plugin scans for interpreters) + if (com.intellij.openapi.util.SystemInfo.isWindows) { + com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } else { + com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin") + } + project = projectRule.project toolkitConnectionManager = spy(ToolkitConnectionManager.getInstance(project)) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts index 824d0f016ff..ae6a6d60746 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -14,6 +15,12 @@ intellijToolkit { dependencies { intellijPlatform { localPlugin(project(":plugin-core")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } compileOnly(project(":plugin-core:jetbrains-community")) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt index b13c71652b6..4e27dcef806 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt @@ -219,20 +219,31 @@ data class LatencyContext( var firstRequestId: String = "", ) { - fun getCodeWhispererEndToEndLatency() = TimeUnit.NANOSECONDS.toMillis( - codewhispererEndToEndEnd - codewhispererEndToEndStart - ).toDouble() + fun getCodeWhispererEndToEndLatency(): Double { + // Guard against uninitialized timestamps which would result in incorrect latency values + if (codewhispererEndToEndStart == 0L || codewhispererEndToEndEnd == 0L) { + return 0.0 + } + return TimeUnit.NANOSECONDS.toMillis( + codewhispererEndToEndEnd - codewhispererEndToEndStart + ).toDouble() + } // For auto-trigger it's from the time when last char typed // for manual-trigger it's from the time when last trigger action happened(alt + c) - fun getPerceivedLatency(triggerType: CodewhispererTriggerType) = - if (triggerType == CodewhispererTriggerType.OnDemand) { + fun getPerceivedLatency(triggerType: CodewhispererTriggerType): Double { + // Guard against uninitialized timestamps which would result in incorrect latency values + if (codewhispererEndToEndEnd == 0L) { + return 0.0 + } + return if (triggerType == CodewhispererTriggerType.OnDemand) { getCodeWhispererEndToEndLatency() } else { TimeUnit.NANOSECONDS.toMillis( codewhispererEndToEndEnd - CodeWhispererAutoTriggerService.getInstance().timeAtLastCharTyped ).toDouble() } + } } data class TryExampleRowContext( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt index 1adcd2a795e..17270ca043a 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt @@ -73,7 +73,8 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() { ) ToolWindowManager.getInstance(projectRule.project).registerToolWindow( RegisterToolWindowTask( - id = CodeWhispererCodeReferenceToolWindowFactory.id + id = CodeWhispererCodeReferenceToolWindowFactory.id, + shouldBeAvailable = false ) ) projectRule.project.service().updateWidget(CodeWhispererStatusBarWidgetFactory::class.java) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt index 80f2bf42a55..cf7c9c3d4c8 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt @@ -117,7 +117,13 @@ open class CodeWhispererTestBase { @Before open fun setUp() = runTest { mockLanguageServer = mockk() - VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin", "C:/Program Files/pypy3.10-v7.3.17-win64") + VfsRootAccess.allowRootAccess( + disposableRule.disposable, + "/usr/bin", + "/usr/local/bin", + "C:/Program Files/pypy3.10-v7.3.17-win64", + "C:/Program Files/pypy3.11-v7.3.20-win64" + ) val starter = object : AmazonQServerInstanceStarter { override fun start( project: Project, @@ -316,18 +322,8 @@ open class CodeWhispererTestBase { val psiFileCaptor = argumentCaptor() val latencyContextCaptor = argumentCaptor() - doSuspendableAnswer { - val requestContext = codewhispererService.getRequestContext( - triggerTypeCaptor.firstValue, - editorCaptor.firstValue, - projectRule.project, - psiFileCaptor.firstValue, - latencyContextCaptor.firstValue - ) - projectRule.fixture.type(userInput) - requestContext - }.doCallRealMethod() - .wheneverBlocking(codewhispererService) { + codewhispererService.stub { + onBlocking { getRequestContext( triggerTypeCaptor.capture(), editorCaptor.capture(), @@ -335,7 +331,18 @@ open class CodeWhispererTestBase { psiFileCaptor.capture(), latencyContextCaptor.capture() ) + } doSuspendableAnswer { + val requestContext = codewhispererService.getRequestContext( + triggerTypeCaptor.firstValue, + editorCaptor.firstValue, + projectRule.project, + psiFileCaptor.firstValue, + latencyContextCaptor.firstValue + ) + projectRule.fixture.type(userInput) + requestContext } + } } fun mockLspInlineCompletionResponse(response: InlineCompletionListWithReferences) { diff --git a/plugins/amazonq/mynah-ui/package-lock.json b/plugins/amazonq/mynah-ui/package-lock.json index 9713c3ae903..829f7f5bb36 100644 --- a/plugins/amazonq/mynah-ui/package-lock.json +++ b/plugins/amazonq/mynah-ui/package-lock.json @@ -366,8 +366,7 @@ "node_modules/@types/node": { "version": "14.18.63", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "peer": true + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" }, "node_modules/@types/sanitize-html": { "version": "2.9.5", @@ -423,7 +422,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -777,7 +775,6 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -816,7 +813,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -990,7 +986,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -1479,7 +1474,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2709,7 +2703,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -3010,7 +3003,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.70.0.tgz", "integrity": "sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==", "dev": true, - "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -3085,7 +3077,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -3499,7 +3490,6 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3614,7 +3604,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -3661,7 +3650,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts index 11c3c2ab792..f91a272d119 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts @@ -48,7 +48,8 @@ export class TabDataGenerator { ], [ 'codetransform', - `Welcome to Code Transformation! **You can also run transformations from the command line. To install the tool, see the [documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/run-CLI-transformations.html).**`, + `Welcome to Code Transformation! + **ℹ️ AWS Transform custom now available for Java upgrades. Agentic AI that handles version upgrades, SDK migration, and more, and improves with every execution. [Learn more](https://aws.amazon.com/transform/custom/)**`, ], ]) diff --git a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts index 36313073d9b..efc0eb759ce 100644 --- a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.intellij.platform.gradle.models.Coordinates import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -16,6 +17,12 @@ dependencies { intellijPlatform { localPlugin(project(":plugin-core")) platformDependency(Coordinates(groupId = "com.jetbrains.intellij.rd", artifactId = "rd-platform")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } compileOnlyApi(project(":plugin-core:jetbrains-community")) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt index 938953d2175..93cad6c64a5 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt @@ -84,10 +84,14 @@ object NodeExePatcher { // attempt to use user provided node runtime path val nodeRuntime = LspSettings.getInstance().getNodeRuntimePath() if (!nodeRuntime.isNullOrEmpty()) { - LOG.info { "Using node from $nodeRuntime " } - - resolveNodeMetric(false, true) - return Path.of(nodeRuntime) + val userNodePath = resolveNodeExecutable(Path.of(nodeRuntime)) + if (userNodePath != null && validateNode(userNodePath) != null) { + LOG.info { "Using node from $userNodePath" } + resolveNodeMetric(false, true) + return userNodePath + } else { + LOG.warn { "User-specified node runtime path is invalid: $nodeRuntime" } + } } // attempt to use bundled node @@ -126,6 +130,35 @@ object NodeExePatcher { } } + /** + * Resolves a path to a Node.js executable. + * If the path is a directory, looks for node.exe (Windows) or node (Unix) inside it. + * If the path is a file, returns it if it exists and is executable. + * + * @param path The path to resolve (can be a directory or file path) + * @return Path? The resolved path to the node executable, or null if not found + */ + private fun resolveNodeExecutable(path: Path): Path? { + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + + return when { + Files.isDirectory(path) -> { + val nodePath = path.resolve(exeName) + if (Files.isRegularFile(nodePath) && Files.isExecutable(nodePath)) { + nodePath.toAbsolutePath() + } else { + LOG.debug { "Node executable not found in directory: $path" } + null + } + } + Files.isRegularFile(path) && Files.isExecutable(path) -> path.toAbsolutePath() + else -> { + LOG.debug { "Invalid node path: $path (not a file or directory)" } + null + } + } + } + /** * Locates node executable ≥18 in system PATH. * Uses IntelliJ's PathEnvironmentVariableUtil to find executables. diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt index 0fbb1553a21..2f654cb8207 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt @@ -9,8 +9,6 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import org.eclipse.lsp4j.ProgressParams import org.eclipse.lsp4j.jsonrpc.ResponseErrorException import software.aws.toolkits.core.utils.getLogger @@ -39,16 +37,21 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap @Service(Service.Level.PROJECT) -class ChatCommunicationManager(private val project: Project, private val cs: CoroutineScope) { +class ChatCommunicationManager(private val project: Project) { val uiReady = CompletableDeferred() private val chatPartialResultMap = ConcurrentHashMap() private val inflightRequestByTabId = ConcurrentHashMap>() private val pendingSerializedChatRequests = ConcurrentHashMap>() private val pendingTabRequests = ConcurrentHashMap>() private val openTabs = mutableSetOf() + private val pendingMessages = ArrayDeque() fun setUiReady() { uiReady.complete(true) + synchronized(pendingMessages) { + pendingMessages.forEach { chatUpdateCallback?.invoke(it) } + pendingMessages.clear() + } } private var chatUpdateCallback: ((FlareUiMessage) -> Unit)? = null @@ -58,10 +61,23 @@ class ChatCommunicationManager(private val project: Project, private val cs: Cor } fun notifyUi(uiMessage: FlareUiMessage) { - cs.launch { - uiReady.await() - chatUpdateCallback?.invoke(uiMessage) + if (!uiReady.isCompleted) { + synchronized(pendingMessages) { + if (!uiReady.isCompleted) { // Double-check + if (uiMessage.command == "aws/chat/sendContextCommands") { + val removed = pendingMessages.removeAll { it.command == "aws/chat/sendContextCommands" } + if (removed) { + LOG.warn { "Removed old aws/chat/sendContextCommands message(s) before UI ready" } + } + } + pendingMessages.addLast(uiMessage) + return + } + } } + + // UI is ready, invoke immediately + chatUpdateCallback?.invoke(uiMessage) } fun setInflightRequestForTab(tabId: String, result: CompletableFuture) { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt index 6334126fcc8..6fa183eb7ca 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -16,7 +16,6 @@ import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager @@ -86,21 +85,12 @@ class TextDocumentServiceHandler( realTimeEdit(event) } } - ApplicationManager.getApplication().runReadAction { - FileDocumentManager.getInstance().getDocument(file)?.addDocumentListener(listener) + val document = ApplicationManager.getApplication().runReadAction { + FileDocumentManager.getInstance().getDocument(file) } + document?.addDocumentListener(listener, this) file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, listener) - Disposer.register(this) { - ApplicationManager.getApplication().runReadAction { - val existingListener = file.getUserData(KEY_REAL_TIME_EDIT_LISTENER) - if (existingListener != null) { - tryOrNull { FileDocumentManager.getInstance().getDocument(file)?.removeDocumentListener(existingListener) } - file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, null) - } - } - } - trySendIfValid { languageServer -> toUriString(file)?.let { uri -> languageServer.textDocumentService.didOpen( diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt index 9ee22f23d52..407b657fa8d 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt @@ -6,6 +6,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.ProcessOutput import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.util.SystemInfo import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.rules.TempDirectory import com.intellij.testFramework.utils.io.createFile @@ -13,6 +14,7 @@ import com.intellij.util.system.CpuArch import io.mockk.every import io.mockk.junit4.MockKRule import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.slot import org.assertj.core.api.Assertions.assertThat @@ -22,6 +24,7 @@ import org.junit.Test import software.aws.toolkits.core.rules.EnvironmentVariableHelper import software.aws.toolkits.core.utils.exists import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl +import software.aws.toolkits.jetbrains.settings.LspSettings import java.nio.file.Paths class NodeExePatcherTest { @@ -122,4 +125,81 @@ class NodeExePatcherTest { assertThat(NodeExePatcher.getNodeRuntimePath(mockk(), Paths.get(pathToNode))) .isNotEqualTo(Paths.get(pathToNode)) } + + @Test + fun `getNodeRuntimePath res olves node executable from user-provided directory path`() { + // Create a directory with node executable inside + val nodeDir = tempDir.newDirectory("nodejs").toPath().toAbsolutePath() + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + val nodeExe = Paths.get(nodeDir.toString(), exeName).createFile() + nodeExe.toFile().setExecutable(true) + + // Mock LspSettings to return the directory path (not the executable) + val mockLspSettings = mockk() + every { mockLspSettings.getNodeRuntimePath() } returns nodeDir.toString() + + mockkObject(LspSettings.Companion) + every { LspSettings.getInstance() } returns mockLspSettings + + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(any(), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + val result = NodeExePatcher.getNodeRuntimePath(mockk(), Paths.get(pathToNode)) + + // Should resolve to the node executable inside the directory + assertThat(result).isEqualTo(nodeExe.toAbsolutePath()) + } + + @Test + fun `getNodeRuntimePath falls back when user-provided directory has no node executable`() { + // Create an empty directory + val emptyDir = tempDir.newDirectory("empty-nodejs").toPath().toAbsolutePath() + + // Create a bundled node as fallback + val bundledNode = tempDir.newFile("bundled-node").toPath().toAbsolutePath() + + // Mock LspSettings to return the empty directory path + val mockLspSettings = mockk() + every { mockLspSettings.getNodeRuntimePath() } returns emptyDir.toString() + + mockkObject(LspSettings.Companion) + every { LspSettings.getInstance() } returns mockLspSettings + + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(any(), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + val result = NodeExePatcher.getNodeRuntimePath(mockk(), bundledNode) + + // Should fall back to bundled node since directory has no node executable + assertThat(result).isEqualTo(bundledNode) + } + + @Test + fun `getNodeRuntimePath uses user-provided executable path directly`() { + // Create a node executable file directly + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + val nodeExe = tempDir.newFile(exeName).toPath().toAbsolutePath() + nodeExe.toFile().setExecutable(true) + + // Mock LspSettings to return the executable path directly + val mockLspSettings = mockk() + every { mockLspSettings.getNodeRuntimePath() } returns nodeExe.toString() + + mockkObject(LspSettings.Companion) + every { LspSettings.getInstance() } returns mockLspSettings + + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(any(), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + val result = NodeExePatcher.getNodeRuntimePath(mockk(), Paths.get(pathToNode)) + + // Should use the user-provided executable path + assertThat(result).isEqualTo(nodeExe.toAbsolutePath()) + } } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt index bde4e2e989e..bea48da6d46 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt @@ -9,9 +9,11 @@ import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess import com.intellij.openapi.vfs.writeText import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.LightVirtualFile @@ -79,6 +81,13 @@ class TextDocumentServiceHandlerTest { @Before fun setup() { + // Allow Python paths for test environment (Python plugin scans for interpreters) + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } else { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin") + } + mockTextDocumentService = mockk() mockLanguageServer = mockk() diff --git a/plugins/core/jetbrains-community/build.gradle.kts b/plugins/core/jetbrains-community/build.gradle.kts index 9a7fbdbc00e..dc9ce8cc69c 100644 --- a/plugins/core/jetbrains-community/build.gradle.kts +++ b/plugins/core/jetbrains-community/build.gradle.kts @@ -4,6 +4,7 @@ import io.gitlab.arturbosch.detekt.Detekt import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions import software.aws.toolkits.telemetry.generator.gradle.GenerateTelemetry plugins { @@ -79,6 +80,15 @@ dependencies { testImplementation(project(":plugin-core:core")) testRuntimeOnly(project(":plugin-core:sdk-codegen")) + + intellijPlatform { + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } + } } // fix implicit dependency on generated source diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt index f2d74100e2c..cbd7299f162 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt @@ -6,6 +6,7 @@ package software.aws.toolkits.jetbrains object ToolkitPlaces { const val DEVTOOLS_TOOL_WINDOW = "DevToolsToolWindow" const val EXPLORER_TOOL_WINDOW = "ExplorerToolWindow" + const val CFN_TOOL_WINDOW = "CloudFormationToolWindow" const val EDITOR_PSI_REFERENCE = "Editor" const val DEV_TOOL_WINDOW = "DeveloperToolsWindow" const val CWQ_TOOL_WINDOW = "CodeWhispererQWindow" diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt index 4f5c174409e..a08245d00ba 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt @@ -8,6 +8,7 @@ import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.project.Project import com.intellij.openapi.util.SystemInfo +import com.intellij.util.text.SemVer import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnectionType import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet @@ -48,7 +49,7 @@ object RulesEngine { private fun matchesIde(notificationIde: SystemType, actualIde: String, actualIdeVersion: String): Boolean { val ide = notificationIde.type?.let { evaluateNotificationExpression(it, actualIde) } ?: true - val ideVersion = notificationIde.version?.let { evaluateNotificationExpression(it, actualIdeVersion) } ?: true + val ideVersion = notificationIde.version?.let { evaluateNotificationExpression(it, actualIdeVersion, true) } ?: true return ide && ideVersion } @@ -57,12 +58,14 @@ object RulesEngine { val extensionsToBeChecked = notificationExtension.map { it.id } val pluginVersions = actualPluginVersions.filterKeys { extensionsToBeChecked.contains(it) } if (pluginVersions.isEmpty()) return false + // SNAPSHOT versions are development versions and should not receive notifications + if (pluginVersions.values.any { it.contains("SNAPSHOT", ignoreCase = true) }) return false return notificationExtension.all { extension -> val actualVersion = pluginVersions[extension.id] if (actualVersion == null) { true } else { - extension.version?.let { evaluateNotificationExpression(it, actualVersion) } ?: true + extension.version?.let { evaluateNotificationExpression(it, actualVersion, true) } ?: true } } } @@ -89,29 +92,52 @@ object RulesEngine { } } - private fun evaluateNotificationExpression(notificationExpression: NotificationExpression, value: String): Boolean = when (notificationExpression) { - is NotificationExpression.NotCondition -> performNotOp(notificationExpression, value) - is NotificationExpression.OrCondition -> performOrOp(notificationExpression, value) - is NotificationExpression.AndCondition -> performAndOp(notificationExpression, value) - is NotificationExpression.ComparisonCondition -> notificationExpression.value == value - is NotificationExpression.NotEqualsCondition -> notificationExpression.value != value - is NotificationExpression.GreaterThanCondition -> value > notificationExpression.value - is NotificationExpression.LessThanCondition -> value < notificationExpression.value - is NotificationExpression.GreaterThanOrEqualsCondition -> value >= notificationExpression.value - is NotificationExpression.LessThanOrEqualsCondition -> value <= notificationExpression.value - is NotificationExpression.AnyOfCondition -> notificationExpression.value.contains(value) - is NotificationExpression.NoneOfCondition -> !notificationExpression.value.contains(value) - else -> true + fun evaluateNotificationExpression(notificationExpression: NotificationExpression, value: String, useSemverForComparison: Boolean = false): Boolean = + when (notificationExpression) { + is NotificationExpression.NotCondition -> performNotOp(notificationExpression, value, useSemverForComparison) + is NotificationExpression.OrCondition -> performOrOp(notificationExpression, value, useSemverForComparison) + is NotificationExpression.AndCondition -> performAndOp(notificationExpression, value, useSemverForComparison) + is NotificationExpression.ComparisonCondition -> notificationExpression.value == value + is NotificationExpression.NotEqualsCondition -> notificationExpression.value != value + is NotificationExpression.GreaterThanCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) > 0 + } else { + value > notificationExpression.value + } + is NotificationExpression.LessThanCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) < 0 + } else { + value < notificationExpression.value + } + is NotificationExpression.GreaterThanOrEqualsCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) >= 0 + } else { + value >= notificationExpression.value + } + is NotificationExpression.LessThanOrEqualsCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) <= 0 + } else { + value <= notificationExpression.value + } + is NotificationExpression.AnyOfCondition -> notificationExpression.value.contains(value) + is NotificationExpression.NoneOfCondition -> !notificationExpression.value.contains(value) + else -> true + } + + private fun compareSemver(actual: String, expected: String): Int { + val actualSemver = SemVer.parseFromText(actual) ?: return actual.compareTo(expected) + val expectedSemver = SemVer.parseFromText(expected) ?: return actual.compareTo(expected) + return actualSemver.compareTo(expectedSemver) } - private fun performNotOp(notificationOperation: NotificationExpression.NotCondition, actualValue: String): Boolean = - !evaluateNotificationExpression(notificationOperation.expectedValue, actualValue) + private fun performNotOp(notificationOperation: NotificationExpression.NotCondition, actualValue: String, useSemverForComparison: Boolean): Boolean = + !evaluateNotificationExpression(notificationOperation.expectedValue, actualValue, useSemverForComparison) - private fun performOrOp(notificationOperation: NotificationExpression.OrCondition, actualValue: String): Boolean = - notificationOperation.expectedValueList.any { evaluateNotificationExpression(it, actualValue) } + private fun performOrOp(notificationOperation: NotificationExpression.OrCondition, actualValue: String, useSemverForComparison: Boolean): Boolean = + notificationOperation.expectedValueList.any { evaluateNotificationExpression(it, actualValue, useSemverForComparison) } - private fun performAndOp(notificationOperation: NotificationExpression.AndCondition, actualValue: String): Boolean = - notificationOperation.expectedValueList.all { evaluateNotificationExpression(it, actualValue) } + private fun performAndOp(notificationOperation: NotificationExpression.AndCondition, actualValue: String, useSemverForComparison: Boolean): Boolean = + notificationOperation.expectedValueList.all { evaluateNotificationExpression(it, actualValue, useSemverForComparison) } } fun getCurrentSystemAndConnectionDetails(): SystemDetails { diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt index cc4fd97f242..6c3404fed1a 100644 --- a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt @@ -97,7 +97,9 @@ class ProfileWatcherTest { } } + // Flaky test - file watcher timing is not deterministic under high system load @Test + @org.junit.Ignore("Flaky: FileWatcher timing is not deterministic") fun `watcher is notified on deletion`() { profileFile.parentFile.mkdirs() profileFile.writeText("Test") diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/RulesEngineTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/RulesEngineTest.kt new file mode 100644 index 00000000000..37f74bdd6c7 --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/RulesEngineTest.kt @@ -0,0 +1,83 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class RulesEngineTest { + + @Test + fun `ComparisonCondition matches equal values`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.ComparisonCondition("test"), "test")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.ComparisonCondition("test"), "other")).isFalse() + } + + @Test + fun `NotEqualsCondition matches different values`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NotEqualsCondition("test"), "other")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NotEqualsCondition("test"), "test")).isFalse() + } + + @Test + fun `GreaterThanCondition compares strings lexicographically`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanCondition("2.0"), "3.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanCondition("2.0"), "1.0")).isFalse() + } + + @Test + fun `LessThanCondition compares strings lexicographically`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanCondition("3.74.0"), "3.101.0", true)).isFalse() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanCondition("2.0"), "3.0")).isFalse() + } + + @Test + fun `GreaterThanOrEqualsCondition includes equality`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanOrEqualsCondition("2.0"), "2.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanOrEqualsCondition("2.0"), "3.0")).isTrue() + } + + @Test + fun `LessThanOrEqualsCondition includes equality`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanOrEqualsCondition("2.0"), "2.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanOrEqualsCondition("2.0"), "1.0")).isTrue() + } + + @Test + fun `AnyOfCondition checks list membership`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.AnyOfCondition(listOf("a", "b")), "b")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.AnyOfCondition(listOf("a", "b")), "c")).isFalse() + } + + @Test + fun `NoneOfCondition checks list non-membership`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NoneOfCondition(listOf("a", "b")), "c")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NoneOfCondition(listOf("a", "b")), "a")).isFalse() + } + + @Test + fun `NotCondition negates result`() { + val expression = NotificationExpression.NotCondition(NotificationExpression.ComparisonCondition("test")) + assertThat(RulesEngine.evaluateNotificationExpression(expression, "test")).isFalse() + assertThat(RulesEngine.evaluateNotificationExpression(expression, "other")).isTrue() + } + + @Test + fun `OrCondition returns true if any condition matches`() { + val expression = NotificationExpression.OrCondition( + listOf(NotificationExpression.ComparisonCondition("a"), NotificationExpression.ComparisonCondition("b")) + ) + assertThat(RulesEngine.evaluateNotificationExpression(expression, "a")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(expression, "c")).isFalse() + } + + @Test + fun `AndCondition returns true only if all conditions match`() { + val expression = NotificationExpression.AndCondition( + listOf(NotificationExpression.GreaterThanCondition("1.0"), NotificationExpression.LessThanCondition("3.0")) + ) + assertThat(RulesEngine.evaluateNotificationExpression(expression, "2.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(expression, "4.0")).isFalse() + } +} diff --git a/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt index 11437defb65..ca36e886e5e 100644 --- a/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt +++ b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt @@ -30,6 +30,7 @@ import com.intellij.testFramework.writeChild import org.junit.runner.Description import org.mockito.Mockito import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import java.nio.file.Paths @@ -71,6 +72,20 @@ open class CodeInsightTestFixtureRule(protected val testDescription: LightProjec lazyFixture.ifSet { try { + // Shutdown LSP servers to prevent thread leaks + try { + val cfnLspProviderClass = Class.forName(CFN_LSP_PROVIDER_CLASS) + val lspServerManagerClass = Class.forName("com.intellij.platform.lsp.api.LspServerManager") + val getInstance = lspServerManagerClass.getMethod("getInstance", Project::class.java) + val lspServerManager = getInstance.invoke(null, fixture.project) + val stopServers = lspServerManagerClass.getMethod("stopServers", Class::class.java) + stopServers.invoke(lspServerManager, cfnLspProviderClass) + LOG.info { "Successfully stopped CFN LSP servers" } + } catch (_: ClassNotFoundException) { + LOG.info { "CFN LSP not available - skipping server shutdown" } + } catch (e: Exception) { + LOG.warn(e) { "Failed to stop CFN LSP servers" } + } fixture.tearDown() } catch (e: Exception) { LOG.warn(e) { "Exception during tear-down" } @@ -99,6 +114,9 @@ open class CodeInsightTestFixtureRule(protected val testDescription: LightProjec private companion object { val LOG = getLogger() + + // CfnLspServerSupportProvider must not be moved/renamed since we are hard-coding its class name + private const val CFN_LSP_PROVIDER_CLASS = "software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider" } } diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index a3cac32bd40..73c0165e275 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -506,18 +506,125 @@ cloudformation.capabilities.iam.toolTipText=Allows templates that contain IAM re cloudformation.capabilities.named_iam=Named IAM cloudformation.capabilities.named_iam.toolTipText=Allows templates that contain IAM resources with custom names to be deployed cloudformation.capabilities.toolTipText=CloudFormation capabilities represent potentially dangerous actions that must be user acknowledged for CloudFormation to perform. +cloudformation.changeset.deletion.failed=Change Set Deletion failed for change set: {0}, in stack: {1} with reason: {2} +cloudformation.changeset.deletion.started=Deletion started for change set: {0}, in stack: {1} +cloudformation.changeset.deletion.success=Deletion completed successfully for change set: {0}, in stack: {1} +cloudformation.changeset.deletion.title=Change Set Deletion cloudformation.create_stack.failed=Failed to create stack {0}: {1} cloudformation.create_stack.failed_validation=Failed to create stack {0} due to validation error cloudformation.create_stack.timeout=Failed to create stack {0} in {1} seconds. View the latest status of the stack via the AWS Console. cloudformation.delete_stack.failed=Failed to delete stack {0}: {1} cloudformation.delete_stack.timeout=Failed to delete stack {0} in {1} seconds. View the latest status of the stack via the AWS Console. -cloudformation.execute_change_set.failed=Failed to execute change set against {0} -cloudformation.execute_change_set.success=Successfully executed change set against {0} -cloudformation.execute_change_set.success.title=Successfully executed change set +cloudformation.deploy.dialog.stack_name.invalid=Stack name must start with a letter and contain only alphanumeric characters and hyphens +cloudformation.deploy.dialog.stack_name.label=Stack name: +cloudformation.deploy.dialog.stack_name.placeholder=Enter the CloudFormation stack name +cloudformation.deploy.dialog.stack_name.required=Stack name is required +cloudformation.deploy.dialog.stack_name.too_long=Stack name must be 128 characters or less +cloudformation.deploy.dialog.template.invalid_extension=Invalid template file extension +cloudformation.deploy.dialog.template.label=Template: +cloudformation.deploy.dialog.template.not_found=Template file does not exist +cloudformation.deploy.dialog.template.required=Template path is required +cloudformation.deploy.dialog.title=Validate and Deploy +cloudformation.deployment.failed=Deployment failed for stack: {0} with reason: {1} +cloudformation.deployment.started=Deployment started for stack: {0} +cloudformation.deployment.success=Deployment completed successfully for stack: {0} +cloudformation.deployment.title=CloudFormation Deployment +cloudformation.explorer.refresh_all=Refresh +cloudformation.explorer.resources.add_type=Add Resource Type +cloudformation.explorer.resources.add_type_node=Add Resource Type +cloudformation.explorer.resources.clone=Clone Resource State +cloudformation.explorer.resources.clone.failed=Failed to clone {0} resource{1} +cloudformation.explorer.resources.clone.none=No resources were cloned +cloudformation.explorer.resources.clone.partial=Cloned {0} resource{1}, {2} failed +cloudformation.explorer.resources.clone.success=Successfully cloned {0} resource{1} +cloudformation.explorer.resources.copy_identifier=Copy Resource Identifier +cloudformation.explorer.resources.dialog.select=Select resource types to monitor: +cloudformation.explorer.resources.dialog.title=Add Resource Type +cloudformation.explorer.resources.import=Import Resource State +cloudformation.explorer.resources.import.failed=Failed to import {0} resource{1} +cloudformation.explorer.resources.import.none=No resources were imported +cloudformation.explorer.resources.import.partial=Imported {0} resource{1}, {2} failed +cloudformation.explorer.resources.import.success=Successfully imported {0} resource{1} +cloudformation.explorer.resources.load_more=Load More... +cloudformation.explorer.resources.loading=Loading {0} resources... +cloudformation.explorer.resources.no_resources=No resources found +cloudformation.explorer.resources.node=Resources +cloudformation.explorer.resources.refresh=Refresh Resources +cloudformation.explorer.resources.refresh_all_loaded=Refresh All Resources +cloudformation.explorer.resources.refresh_type=Refresh Resource List +cloudformation.explorer.resources.remove_type=Remove Resource Type from List +cloudformation.explorer.resources.search=Search Resource +cloudformation.explorer.resources.search.prompt=Enter resource identifier to search for in {0}: +cloudformation.explorer.resources.search.title=Search Resource +cloudformation.explorer.resources.stack_info=Get Stack Management Info +cloudformation.explorer.resources.stack_info.copy_arn=Copy Stack ARN +cloudformation.explorer.resources.stack_info.copy_name=Copy Stack Name +cloudformation.explorer.resources.stack_info.error=Failed to get stack management info +cloudformation.explorer.resources.stack_info.managed=Resource is managed by CloudFormation stack: {0} +cloudformation.explorer.resources.stack_info.not_managed=Resource is not managed by CloudFormation +cloudformation.explorer.resources.stack_info.title=Stack Management Info +cloudformation.explorer.sign_in=Sign in to get started +cloudformation.explorer.stacks.change_sets=Change Sets +cloudformation.explorer.stacks.load_more=load more... +cloudformation.explorer.stacks.load_more_stacks=Load More Stacks +cloudformation.explorer.stacks.node_name=Stacks +cloudformation.explorer.stacks.refresh=Refresh Stacks +cloudformation.explorer.tab.title=CloudFormation cloudformation.invalid_property=Property {0} has invalid value {1} cloudformation.key_not_found={0} not found on resource {1} +cloudformation.lsp.action.configure_node=Configure Node.js +cloudformation.lsp.action.download_node=Download Node.js +cloudformation.lsp.error.download_failed=Failed to download CloudFormation LSP. Check your network connection. +cloudformation.lsp.error.extraction_failed=Failed to extract CloudFormation LSP. +cloudformation.lsp.error.hash_mismatch=Downloaded file integrity check failed. The file may be corrupted. +cloudformation.lsp.error.manifest_failed=Failed to fetch CloudFormation LSP manifest. Check your network connection. +cloudformation.lsp.error.no_compatible_version=No compatible CloudFormation LSP version found for your platform. +cloudformation.lsp.error.node_not_found=Node.js not found. Install or configure Node.js for CloudFormation Language Server. +cloudformation.lsp.error.title=CloudFormation Language Server +cloudformation.lsp.intro.prompt.action.dont_show=Don't show again +cloudformation.lsp.intro.prompt.action.explore=Open CloudFormation Panel +cloudformation.lsp.intro.prompt.action.learn_more=Learn more +cloudformation.lsp.intro.prompt.message=Author templates with hover, code completion, and diagnostic support, validate and deploy templates to stacks, and view stack information such as events and resources +cloudformation.lsp.intro.prompt.title=Introducing CloudFormation Language Server +cloudformation.lsp.stack.view=CloudFormation Stack cloudformation.missing_property=Property {0} not found in {1} -cloudformation.service_name=AWS CloudFormation +cloudformation.settings.cfnguard.enable=Enable CloudFormation Guard validation +cloudformation.settings.cfnguard.enabledRulePacks=Enabled rule packs: +cloudformation.settings.cfnguard.group=CFN-Guard +cloudformation.settings.cfnguard.rulesFile=Custom rules file: +cloudformation.settings.cfnguard.rulesFile.comment=Path to custom cfn-guard rules file. If set, overrides the selected rule packs above. +cloudformation.settings.cfnguard.validateOnChange=Validate on document change +cloudformation.settings.cfnlint.appendRules=Append rules: +cloudformation.settings.cfnlint.appendRules.comment=Comma-separated additional rule directories +cloudformation.settings.cfnlint.customRules=Custom rules: +cloudformation.settings.cfnlint.customRules.comment=Comma-separated paths to custom rule files +cloudformation.settings.cfnlint.delayMs=Delay (ms): +cloudformation.settings.cfnlint.delayMs.comment=Delay before running cfn-lint after changes +cloudformation.settings.cfnlint.enable=Enable CloudFormation linting +cloudformation.settings.cfnlint.group=CFN-Lint +cloudformation.settings.cfnlint.ignoreChecks=Ignore checks: +cloudformation.settings.cfnlint.ignoreChecks.comment=Comma-separated rule IDs to ignore (e.g., W3002,E1001) +cloudformation.settings.cfnlint.includeChecks=Include checks: +cloudformation.settings.cfnlint.includeChecks.comment=Comma-separated rule IDs to include (e.g., I) +cloudformation.settings.cfnlint.includeExperimental=Include experimental rules +cloudformation.settings.cfnlint.lintOnChange=Lint on document change +cloudformation.settings.cfnlint.overrideSpec=Override spec: +cloudformation.settings.cfnlint.overrideSpec.comment=CloudFormation spec override file path +cloudformation.settings.cfnlint.path=cfn-lint path: +cloudformation.settings.cfnlint.path.comment=Path to cfn-lint executable (leave empty for bundled version) +cloudformation.settings.cfnlint.registrySchemas=Registry schemas: +cloudformation.settings.cfnlint.registrySchemas.comment=Comma-separated CloudFormation Registry schema paths +cloudformation.settings.completion.enable=Enable auto-completion +cloudformation.settings.completion.group=Completion +cloudformation.settings.completion.max=Maximum completions: +cloudformation.settings.general.group=General +cloudformation.settings.hover.enable=Enable hover information +cloudformation.settings.hover.group=Hover +cloudformation.settings.node.path=Node.js path: +cloudformation.settings.node.path.browse=Select Node.js Executable +cloudformation.settings.node.path.comment=Path to Node.js executable (leave empty for auto-detection) +cloudformation.settings.telemetry.enable=Enable anonymous telemetry +cloudformation.settings.title=CloudFormation cloudformation.stack.delete.action=Delete Stack... cloudformation.stack.filter.show_completed=Show Completed cloudformation.stack.logical_id=Logical ID @@ -537,12 +644,22 @@ cloudformation.stack.tab_labels.events=Events cloudformation.stack.tab_labels.outputs=Outputs cloudformation.stack.tab_labels.resources=Resources cloudformation.stack.type=Type -cloudformation.stack.view=View Stack Status +cloudformation.stack.view=View Stack Detail +cloudformation.telemetry.prompt.action.allow=Yes, allow +cloudformation.telemetry.prompt.action.learn_more=Learn more +cloudformation.telemetry.prompt.action.never=Never +cloudformation.telemetry.prompt.action.not_now=Not now +cloudformation.telemetry.prompt.message=Help us improve the AWS CloudFormation Language Server by sharing anonymous telemetry data with AWS. You can change this preference at any time in CloudFormation Settings. +cloudformation.telemetry.prompt.title=AWS CloudFormation Language Server cloudformation.template_index.missing_type=Resource type must not be null for indexing cloudformation.toolwindow.label=CloudFormation cloudformation.update_stack.failed=Failed to update stack {0}: {1} cloudformation.update_stack.failed_validation=Failed to update stack {0} due to validation error cloudformation.update_stack.timeout=Failed to update stack {0} in {1} seconds. View the latest status of the stack via the AWS Console. +cloudformation.validation.failed=Validation failed for stack: {0} with reason: {1} +cloudformation.validation.started=Validation started for stack: {0} +cloudformation.validation.success=Validation completed successfully for stack: {0} +cloudformation.validation.title=CloudFormation Validation cloudformation.yaml.invalid_root_type=Template does not start with a mapping: {0} cloudformation.yaml.too_many_documents=There should only be 1 YAML document per file: {0} cloudformation.yaml.too_many_files=Found {0} YAML files but only expected 1 diff --git a/plugins/core/webview/package-lock.json b/plugins/core/webview/package-lock.json index d0fd3258687..5d32af8fda8 100644 --- a/plugins/core/webview/package-lock.json +++ b/plugins/core/webview/package-lock.json @@ -304,8 +304,7 @@ "node_modules/@types/node": { "version": "14.18.63", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "peer": true + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" }, "node_modules/@types/sanitize-html": { "version": "2.11.0", @@ -361,7 +360,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -810,7 +808,6 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -849,7 +846,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1032,7 +1028,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -1527,7 +1522,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2789,7 +2783,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -3090,7 +3083,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz", "integrity": "sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==", "dev": true, - "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -3165,7 +3157,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -3579,7 +3570,6 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3658,7 +3648,6 @@ "version": "3.4.20", "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.20.tgz", "integrity": "sha512-xF4zDKXp67NjgORFX/HOuaiaKYjgxkaToK0KWglFQEYlCw9AqgBlj1yu5xa6YaRek47w2IGiuvpvrGg/XuQFCw==", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.4.20", "@vue/compiler-sfc": "3.4.20", @@ -3747,7 +3736,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -3794,7 +3782,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", diff --git a/plugins/toolkit/jetbrains-core/build.gradle.kts b/plugins/toolkit/jetbrains-core/build.gradle.kts index dc32bdd361c..b133b49ba98 100644 --- a/plugins/toolkit/jetbrains-core/build.gradle.kts +++ b/plugins/toolkit/jetbrains-core/build.gradle.kts @@ -195,6 +195,7 @@ dependencies { // TODO: remove Q dependency when split is fully done implementation(libs.bundles.jackson) implementation(libs.zjsonpatch) + implementation(libs.nimbus.jose.jwt) testFixturesApi(testFixtures(project(":plugin-core:jetbrains-community"))) } diff --git a/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntegrationTest.kt b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntegrationTest.kt new file mode 100644 index 00000000000..0846eda60ef --- /dev/null +++ b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntegrationTest.kt @@ -0,0 +1,151 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.CompletionParams +import org.eclipse.lsp4j.DefinitionParams +import org.eclipse.lsp4j.DocumentSymbolParams +import org.eclipse.lsp4j.HoverParams +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * E2E integration test for CloudFormation LSP. + * Downloads the real language server and validates autocomplete, hover, + * go-to-definition, and document symbols via direct LSP protocol requests. + * + * Requires Node.js 18+ on PATH. Hard-fails if not found. + */ +class CfnLspIntegrationTest { + + private lateinit var lsp: CfnLspTestFixture + + @Before + fun setUp() { + val nodeAvailable = try { + val process = ProcessBuilder("node", "--version").start() + val exitCode = process.waitFor() + if (exitCode == 0) { + val version = process.inputStream.bufferedReader().readText().trim() + version.removePrefix("v").split(".").firstOrNull()?.toIntOrNull()?.let { it >= 18 } ?: false + } else { + false + } + } catch (_: Exception) { false } + + assertThat(nodeAvailable) + .withFailMessage("Node.js 18+ is required to run CloudFormation LSP integration tests") + .isTrue() + + val factory = IdeaTestFixtureFactory.getFixtureFactory() + val projectFixture = factory.createLightFixtureBuilder("CfnLspIntegTest").fixture + val codeFixture = factory.createCodeInsightFixture(projectFixture) + codeFixture.setUp() + lsp = CfnLspTestFixture(codeFixture) + } + + @After + fun tearDown() { + lsp.tearDown() + } + + @Test + fun `autocomplete provides CloudFormation top-level sections`() { + val file = lsp.openTemplate("top-level.yaml", "AWSTemplateFormatVersion: \"2010-09-09\"\n") + val labels = lspComplete(lsp.fileUri(file), Position(1, 0)) + assertThat(labels).anyMatch { it.contains("Resources") || it.contains("Parameters") || it.contains("Outputs") } + } + + @Test + fun `autocomplete provides resource types`() { + val file = lsp.openTemplate( + "resource-type.yaml", + "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n MyBucket:\n Type: " + ) + val labels = lspComplete(lsp.fileUri(file), Position(3, 10)) + assertThat(labels).anyMatch { it.startsWith("AWS::") } + } + + @Test + fun `autocomplete provides resource properties`() { + val file = lsp.openTemplate( + "resource-props.yaml", + "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n MyBucket:\n Type: AWS::S3::Bucket\n Properties:\n " + ) + val labels = lspComplete(lsp.fileUri(file), Position(5, 6)) + assertThat(labels).anyMatch { it.contains("BucketName") } + } + + @Test + fun `hover provides documentation for resource types`() { + val file = lsp.openTemplate( + "hover-resource.yaml", + "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n MyBucket:\n Type: AWS::S3::Bucket" + ) + val hover = lsp.request { it.textDocumentService.hover(HoverParams(TextDocumentIdentifier(lsp.fileUri(file)), Position(3, 15))) } + assertThat(hover).isNotNull() + } + + @Test + fun `go-to-definition navigates to parameter from Ref`() { + val file = lsp.openTemplate( + "definition-param.yaml", + """ + AWSTemplateFormatVersion: "2010-09-09" + Parameters: + MyParam: + Type: String + Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref MyParam + """.trimIndent() + ) + val definition = lsp.request { + it.textDocumentService.definition( + DefinitionParams(TextDocumentIdentifier(lsp.fileUri(file)), Position(8, 25)) + ) + } + assertThat(definition).isNotNull() + } + + @Test + fun `document symbols provides template outline`() { + val file = lsp.openTemplate( + "symbols.yaml", + """ + AWSTemplateFormatVersion: "2010-09-09" + Parameters: + MyParam: + Type: String + Resources: + MyBucket: + Type: AWS::S3::Bucket + Outputs: + BucketName: + Value: !Ref MyBucket + """.trimIndent() + ) + val symbols = lsp.request { + it.textDocumentService.documentSymbol( + DocumentSymbolParams(TextDocumentIdentifier(lsp.fileUri(file))) + ) + } + assertThat(symbols).isNotNull() + assertThat(symbols).isNotEmpty() + } + + private fun lspComplete(uri: String, position: Position): List { + val result = lsp.request { it.textDocumentService.completion(CompletionParams(TextDocumentIdentifier(uri), position)) } + ?: return emptyList() + val items = if (result.isLeft) result.left else result.right?.items + return items?.map { it.label }.orEmpty() + } +} diff --git a/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspTestFixture.kt b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspTestFixture.kt new file mode 100644 index 00000000000..b8fe3ffcc51 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspTestFixture.kt @@ -0,0 +1,142 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.application.runWriteActionAndWait +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServerManager +import com.intellij.platform.lsp.api.LspServerState +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.runInEdtAndWait +import kotlinx.coroutines.runBlocking +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DocumentSymbolParams +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.services.LanguageServer +import software.aws.toolkits.core.utils.Waiters +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor +import java.time.Duration +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +private const val LSP_REQUEST_TIMEOUT_S = 30L + +/** + * Manages CloudFormation LSP server lifecycle and document operations for integration tests. + */ +internal class CfnLspTestFixture(private val fixture: CodeInsightTestFixture) { + + private var schemasReady = false + + fun tearDown() = fixture.tearDown() + + fun openTemplate(name: String, content: String): VirtualFile { + var file: VirtualFile? = null + runWriteActionAndWait { file = fixture.tempDirFixture.createFile(name, content) } + val vf = file ?: error("Failed to create test file") + runInEdtAndWait { fixture.openFileInEditor(vf) } + ensureRunning() + ensureSchemasLoaded() + + request { lsp -> + lsp.textDocumentService.didOpen( + DidOpenTextDocumentParams(TextDocumentItem(fileUri(vf), "yaml", 1, content)) + ) + CompletableFuture.completedFuture(Unit) + } + waitForDocumentProcessed(vf) + return vf + } + + fun fileUri(file: VirtualFile): String = file.toNioPath().toUri().toString() + + fun request(block: (LanguageServer) -> CompletableFuture): T { + val future = CompletableFuture() + runningServer().sendNotification { lsp -> + block(lsp).whenComplete { result, error -> + if (error != null) { + future.completeExceptionally(error) + } else { + future.complete(result) + } + } + } + return future.get(LSP_REQUEST_TIMEOUT_S, TimeUnit.SECONDS) + } + + private fun runningServer() = + LspServerManager.getInstance(fixture.project) + .getServersForProvider(CfnLspServerDescriptor.providerClass()) + .first { it.state == LspServerState.Running } + + private fun ensureRunning() { + val providerClass = CfnLspServerDescriptor.providerClass() + LspServerManager.getInstance(fixture.project) + .ensureServerStarted(providerClass, CfnLspServerDescriptor.getInstance(fixture.project)) + + runBlocking { + Waiters.waitUntil( + succeedOn = { it }, + maxDuration = Duration.ofSeconds(120), + ) { + val servers = LspServerManager.getInstance(fixture.project).getServersForProvider(providerClass) + servers.any { it.state == LspServerState.Running } + } + } ?: throw AssertionError("CloudFormation LSP server did not reach Running state") + } + + /** + * Calls aws/cfn/resources/types to confirm public schemas have been loaded. + */ + private fun ensureSchemasLoaded() { + if (schemasReady) return + + val protocolClass = Class.forName("software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspServerProtocol") + val listResourceTypes = protocolClass.getMethod("listResourceTypes") + + runBlocking { + Waiters.waitUntil( + succeedOn = { it }, + maxDuration = Duration.ofSeconds(60), + ) { + val future = CompletableFuture() + runningServer().sendNotification { lsp -> + if (protocolClass.isInstance(lsp)) { + @Suppress("UNCHECKED_CAST") + val resultFuture = listResourceTypes.invoke(lsp) as CompletableFuture + resultFuture.whenComplete { result, error -> + val types = result?.javaClass?.getMethod("getResourceTypes")?.invoke(result) as? List<*> + future.complete(error == null && types?.isNotEmpty() == true) + } + } else { + future.complete(false) + } + } + future.get(LSP_REQUEST_TIMEOUT_S, TimeUnit.SECONDS) + } + } ?: throw AssertionError("CloudFormation LSP schemas not loaded") + + schemasReady = true + } + + /** + * Requests document symbols to confirm the server has parsed the document after didOpen. + */ + private fun waitForDocumentProcessed(file: VirtualFile) { + runBlocking { + Waiters.waitUntil( + succeedOn = { it == true }, + maxDuration = Duration.ofSeconds(10), + ) { + val result = request { lsp -> + lsp.textDocumentService.documentSymbol( + DocumentSymbolParams(TextDocumentIdentifier(fileUri(file))) + ) + } + result?.isNotEmpty() + } + } ?: throw AssertionError("Document not processed by server") + } +} diff --git a/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml new file mode 100644 index 00000000000..72f5a01fadf --- /dev/null +++ b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml index 39be6ec4e6d..cbc0774499f 100644 --- a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml +++ b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml @@ -12,9 +12,6 @@ Amazon Q and CodeWhisperer -

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

-

Amazon CodeCatalyst

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

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

+

AWS CloudFormation Language Server

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

View, modify, and deploy AWS resources

    @@ -106,6 +114,7 @@ com.intellij.modules.java com.intellij.modules.python com.jetbrains.gateway + com.intellij.modules.lsp @@ -172,6 +181,7 @@ + @@ -180,6 +190,7 @@ + @@ -263,6 +274,7 @@ instance="software.aws.toolkits.jetbrains.settings.DynamicResourcesConfigurable" /> + @@ -277,6 +289,9 @@ + + + diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnClientService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnClientService.kt new file mode 100644 index 00000000000..83beb980690 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnClientService.kt @@ -0,0 +1,210 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.LspServerManager +import org.eclipse.lsp4j.DidChangeConfigurationParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.TextDocumentItem +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ClearStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateDeploymentParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeleteChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeletionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeploymentStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetCapabilitiesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetParametersResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateArtifactsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStackResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTypesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider +import java.util.concurrent.CompletableFuture + +@Service(Service.Level.PROJECT) +internal class CfnClientService(private val project: Project) : Disposable { + private val lspServerProvider: () -> LspServer? = { + LspServerManager.getInstance(project) + .getServersForProvider(CfnLspServerSupportProvider::class.java) + .firstOrNull() + } + + fun updateIamCredentials(params: UpdateCredentialsParams): CompletableFuture = + sendRequest { it.updateIamCredentials(params) } + + fun listStacks(params: ListStacksParams): CompletableFuture = + sendRequest { it.listStacks(params) } + + fun listChangeSets(params: ListChangeSetsParams): CompletableFuture = + sendRequest { it.listChangeSets(params) } + + fun createValidation(params: CreateValidationParams): CompletableFuture = + sendRequest { it.createValidation(params) } + + fun getValidationStatus(params: Identifiable): CompletableFuture = + sendRequest { it.getValidationStatus(params) } + + fun describeValidationStatus(params: Identifiable): CompletableFuture = + sendRequest { it.describeValidationStatus(params) } + + fun describeChangeSet(params: DescribeChangeSetParams): CompletableFuture = + sendRequest { it.describeChangeSet(params) } + + fun deleteChangeSet(params: DeleteChangeSetParams): CompletableFuture = + sendRequest { it.deleteChangeSet(params) } + + fun getChangeSetDeletionStatus(params: Identifiable): CompletableFuture = + sendRequest { it.getChangeSetDeletionStatus(params) } + + fun describeChangeSetDeletionStatus(params: Identifiable): CompletableFuture = + sendRequest { it.describeChangeSetDeletionStatus(params) } + + fun createDeployment(params: CreateDeploymentParams): CompletableFuture = + sendRequest { it.createDeployment(params) } + + fun getDeploymentStatus(params: Identifiable): CompletableFuture = + sendRequest { it.getDeploymentStatus(params) } + + fun describeDeploymentStatus(params: Identifiable): CompletableFuture = + sendRequest { it.describeDeploymentStatus(params) } + + fun getParameters(uri: String): CompletableFuture = + sendRequest { it.getParameters(uri) } + + fun getCapabilities(uri: String): CompletableFuture = + sendRequest { it.getCapabilities(uri) } + + fun getTemplateResources(uri: String): CompletableFuture = + sendRequest { it.getTemplateResources(uri) } + + fun getTemplateArtifacts(uri: String): CompletableFuture = + sendRequest { it.getTemplateArtifacts(uri) } + + fun describeStack(params: DescribeStackParams): CompletableFuture = + sendRequest { it.describeStack(params) } + + fun listResourceTypes(): CompletableFuture = + sendRequest { it.listResourceTypes() } + + fun removeResourceType(resourceType: String): CompletableFuture = + sendRequest { it.removeResourceType(resourceType) } + + fun searchResource(params: SearchResourceParams): CompletableFuture = + sendRequest { it.searchResource(params) } + + fun listResources(params: ListResourcesParams): CompletableFuture = + sendRequest { it.listResources(params) } + + fun refreshResources(params: RefreshResourcesParams): CompletableFuture = + sendRequest { it.refreshResources(params) } + + fun getStackManagementInfo(resourceIdentifier: String): CompletableFuture = + sendRequest { it.getStackManagementInfo(resourceIdentifier) } + + fun getResourceState(params: ResourceStateParams): CompletableFuture = + sendRequest { it.getResourceState(params) } + + fun ensureDocumentOpen(file: VirtualFile, project: Project) { + val isOpenInEditor = FileEditorManager.getInstance(project).isFileOpen(file) + if (isOpenInEditor) return + + val server = lspServerProvider() ?: return + val descriptor = server.descriptor + val uri = descriptor.getFileUri(file) + val languageId = descriptor.getLanguageId(file) + val content = FileDocumentManager.getInstance().getDocument(file)?.text ?: return + + server.sendNotification { lsp -> + lsp.textDocumentService.didOpen( + DidOpenTextDocumentParams(TextDocumentItem(uri, languageId, 0, content)) + ) + } + } + + fun notifyConfigurationChanged() { + lspServerProvider()?.sendNotification { lsp -> + lsp.workspaceService.didChangeConfiguration(DidChangeConfigurationParams(emptyMap())) + } + } + + private fun sendRequest(request: (CfnLspServerProtocol) -> CompletableFuture): CompletableFuture { + val future = CompletableFuture() + val server = lspServerProvider() + if (server == null) { + future.complete(null) + return future + } + server.sendNotification { lsp -> + (lsp as? CfnLspServerProtocol)?.let { cfn -> + request(cfn).whenComplete { result, error -> + if (error != null) { + future.completeExceptionally(error) + } else { + future.complete(result) + } + } + } ?: future.complete(null) + } + return future + } + + fun getStackResources(params: GetStackResourcesParams): CompletableFuture = + sendRequest { it.getStackResources(params) } + + fun getStackEvents(params: GetStackEventsParams): CompletableFuture = + sendRequest { it.getStackEvents(params) } + + fun clearStackEvents(params: ClearStackEventsParams): CompletableFuture = + sendRequest { it.clearStackEvents(params) } + + override fun dispose() { + try { + LspServerManager.getInstance(project).stopServers(CfnLspServerSupportProvider::class.java) + } catch (e: Exception) { + LOG.warn(e) { "Failed to stop CFN LSP servers during disposal" } + } + } + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project): CfnClientService = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt new file mode 100644 index 00000000000..79319038a80 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt @@ -0,0 +1,194 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.LspServerManager +import com.intellij.platform.lsp.api.LspServerManagerListener +import com.intellij.platform.lsp.api.LspServerState +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.DirectEncrypter +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsStateChangeNotifier +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.jetbrains.settings.CfnLspSettingsChangeListener +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * Manages AWS credentials for the CloudFormation LSP server. + * + * Credentials are JWE-encrypted (A256GCM) before being sent to the server. + * The encryption key is generated per-session and passed in initialization options. + */ +@Service(Service.Level.PROJECT) +internal class CfnCredentialsService(private val project: Project) : Disposable { + private val encryptionKey: SecretKey = generateKey() + private var lastRegionId: String? = AwsConnectionManager.getInstance(project).selectedRegion?.id + + init { + val appBus = ApplicationManager.getApplication().messageBus.connect(this) + subscribeToCredentialChanges(appBus) + subscribeToSettingsChanges(appBus) + subscribeToServerStateChanges() + } + + val encryptionKeyBase64: String + get() = Base64.getEncoder().encodeToString(encryptionKey.encoded) + + fun sendCredentials(onRegionChange: Boolean = false) { + val credentials = resolveCredentials() ?: return + val encrypted = encrypt(credentials) + CfnClientService.getInstance(project).updateIamCredentials(UpdateCredentialsParams(encrypted, true)) + .thenAccept { result -> + LOG.info { "Credentials updated on LSP server: success=${result?.success}" } + if (onRegionChange && result?.success == true) { + val stacksManager = StacksManager.getInstance(project) + stacksManager.clear() + + val resourceLoader = ResourceLoader.getInstance(project) + resourceLoader.clear(null) + + stacksManager.reload() + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to update credentials on LSP server" } + null + } + } + + fun notifyConfigurationChanged() { + CfnClientService.getInstance(project).notifyConfigurationChanged() + LOG.info { "Sent didChangeConfiguration to LSP server" } + } + + private fun subscribeToCredentialChanges(appBus: com.intellij.util.messages.MessageBusConnection) { + appBus.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + sendCredentials() + } + } + ) + + project.messageBus.connect(this).subscribe( + AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, + object : ConnectionSettingsStateChangeNotifier { + override fun settingsStateChanged(newState: ConnectionState) { + if (newState is ConnectionState.ValidConnection) { + val newRegionId = newState.region.id + val regionChanged = lastRegionId != null && lastRegionId != newRegionId + lastRegionId = newRegionId + sendCredentials(onRegionChange = regionChanged) + } + } + } + ) + } + + @Suppress("UnstableApiUsage") + private fun subscribeToSettingsChanges(appBus: com.intellij.util.messages.MessageBusConnection) { + appBus.subscribe( + CfnLspSettingsChangeListener.TOPIC, + CfnLspSettingsChangeListener { + notifyConfigurationChanged() + LspServerManager.getInstance(project).stopAndRestartIfNeeded(CfnLspServerSupportProvider::class.java) + } + ) + } + + @Suppress("UnstableApiUsage") + private fun subscribeToServerStateChanges() { + LspServerManager.getInstance(project).addLspServerManagerListener( + object : LspServerManagerListener { + override fun serverStateChanged(lspServer: LspServer) { + if (lspServer.state == LspServerState.Running) { + LOG.info { "LSP server running, sending credentials" } + sendCredentials() + } + } + }, + this, + true + ) + } + + private fun resolveCredentials(): IamCredentials? { + val connectionManager = AwsConnectionManager.getInstance(project) + val region = connectionManager.selectedRegion ?: return null + + return try { + val credentialProvider = connectionManager.activeCredentialProvider + val awsCredentials = credentialProvider.resolveCredentials() + val sessionCredentials = awsCredentials as? AwsSessionCredentials + + IamCredentials( + profile = credentialProvider.shortName, + region = region.id, + accessKeyId = awsCredentials.accessKeyId(), + secretAccessKey = awsCredentials.secretAccessKey(), + sessionToken = sessionCredentials?.sessionToken() + ) + } catch (e: Exception) { + LOG.warn(e) { "Failed to resolve credentials" } + null + } + } + + private fun encrypt(credentials: IamCredentials): String { + val payload = """{"data":${jacksonObjectMapper().writeValueAsString(credentials)}}""" + val jwe = JWEObject( + JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM), + Payload(payload) + ) + jwe.encrypt(DirectEncrypter(encryptionKey)) + return jwe.serialize() + } + + override fun dispose() {} + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project): CfnCredentialsService = project.service() + + private fun generateKey(): SecretKey { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return SecretKeySpec(bytes, "AES") + } + } +} + +internal data class IamCredentials( + val profile: String, + val region: String, + val accessKeyId: String, + val secretAccessKey: String, + val sessionToken: String? = null, +) diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptState.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptState.kt new file mode 100644 index 00000000000..120b6e03993 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptState.kt @@ -0,0 +1,31 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +data class CfnLspIntroPromptStateData( + var hasResponded: Boolean = false, +) + +@Service +@State(name = "cfnLspIntroPromptState", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnLspIntroPromptState : PersistentStateComponent { + private var state = CfnLspIntroPromptStateData() + + override fun getState(): CfnLspIntroPromptStateData = state + override fun loadState(state: CfnLspIntroPromptStateData) { this.state = state } + + fun hasResponded(): Boolean = state.hasResponded + fun setResponded() { state.hasResponded = true } + + companion object { + fun getInstance(): CfnLspIntroPromptState = service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt new file mode 100644 index 00000000000..99e0b2503d8 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt @@ -0,0 +1,68 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.ide.BrowserUtil +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import software.aws.toolkits.jetbrains.core.explorer.AwsToolkitExplorerToolWindow +import software.aws.toolkits.resources.AwsToolkitBundle.message + +private const val LANGUAGE_SERVER_DOCS_URL = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/ide-extension.html" + +internal class CfnLspIntroPrompter : ProjectActivity { + override suspend fun execute(project: Project) { + if (CfnLspIntroPromptState.getInstance().hasResponded()) return + + showPrompt(project) + } + + private fun showPrompt(project: Project) { + val notification = Notification( + CfnLspExtensionConfig.INTRO_NOTIFICATION_GROUP_ID, + message("cloudformation.lsp.intro.prompt.title"), + message("cloudformation.lsp.intro.prompt.message"), + NotificationType.INFORMATION + ) + + notification.addAction(object : NotificationAction(message("cloudformation.lsp.intro.prompt.action.explore")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + openCloudFormationTab(project) + applyChoice() + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.lsp.intro.prompt.action.learn_more")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + BrowserUtil.browse(LANGUAGE_SERVER_DOCS_URL) + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.lsp.intro.prompt.action.dont_show")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice() + notification.expire() + } + }) + + notification.notify(project) + } + + private fun openCloudFormationTab(project: Project) { + val explorerToolWindow = AwsToolkitExplorerToolWindow.toolWindow(project) + explorerToolWindow.activate(null, true) + + val cfnTabTitle = message("cloudformation.explorer.tab.title") + AwsToolkitExplorerToolWindow.getInstance(project).selectTab(cfnTabTitle) + } + + private fun applyChoice() { + CfnLspIntroPromptState.getInstance().setResponded() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt new file mode 100644 index 00000000000..2320e042b87 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt @@ -0,0 +1,20 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.platform.lsp.api.LspServerManager +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider + +internal class CfnLspStartupActivity : ProjectActivity { + override suspend fun execute(project: Project) { + CfnCredentialsService.getInstance(project) // eagerly initialize to register settings change listener + LspServerManager.getInstance(project).ensureServerStarted( + CfnLspServerSupportProvider::class.java, + CfnLspServerDescriptor.getInstance(project) + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManager.kt new file mode 100644 index 00000000000..f8faae0c6a3 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManager.kt @@ -0,0 +1,41 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.documents + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info + +internal data class DocumentMetadata( + val uri: String, + val fileName: String, + val ext: String, + val type: String, + val cfnType: String, + val languageId: String, + val version: Int, + val lineCount: Int, +) + +@Service(Service.Level.PROJECT) +internal class CfnDocumentManager { + private val documents = mutableListOf() + + fun getValidTemplates(): List = + documents.filter { it.cfnType == "template" } + + fun updateDocuments(newDocuments: List) { + LOG.info { "Updating documents to: $newDocuments" } + documents.clear() + documents.addAll(newDocuments) + } + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project): CfnDocumentManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationRootNode.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationRootNode.kt new file mode 100644 index 00000000000..5191b5e1821 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationRootNode.kt @@ -0,0 +1,28 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourcesNode +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StacksNode +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +class CloudFormationRootNode(private val nodeProject: Project) : AbstractTreeNode(nodeProject, Any()) { + private val stacksManager by lazy { StacksManager.getInstance(nodeProject) } + private val changeSetsManager by lazy { ChangeSetsManager.getInstance(nodeProject) } + private val resourceTypesManager by lazy { ResourceTypesManager.getInstance(nodeProject) } + private val resourceLoader by lazy { ResourceLoader.getInstance(nodeProject) } + + override fun update(presentation: PresentationData) {} + + override fun getChildren(): Collection> = listOf( + StacksNode(nodeProject, stacksManager, changeSetsManager), + ResourcesNode(nodeProject, resourceTypesManager, resourceLoader) + ) +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindow.kt new file mode 100644 index 00000000000..5a0c83abd1e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindow.kt @@ -0,0 +1,112 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsStateChangeNotifier +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.explorer.AbstractExplorerTreeToolWindow +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForExplorer +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.jetbrains.ui.CenteredInfoPanel +import software.aws.toolkits.resources.AwsToolkitBundle.message + +@Service(Service.Level.PROJECT) +internal class CloudFormationToolWindow(private val project: Project) : + AbstractExplorerTreeToolWindow( + CloudFormationTreeStructure(project), + initialTreeExpandDepth = 0 + ), + ConnectionSettingsStateChangeNotifier { + override val actionPlace = ToolkitPlaces.CFN_TOOL_WINDOW + + init { + val toolbarGroup = ActionManager.getInstance().getAction("aws.toolkit.cloudformation.toolbar") + toolbar = ActionManager.getInstance().createActionToolbar(actionPlace, toolbarGroup as ActionGroup, true).apply { + targetComponent = this@CloudFormationToolWindow + }.component + + StacksManager.getInstance(project).addListener { + runInEdt { redrawContent() } + } + ChangeSetsManager.getInstance(project).addListener { + runInEdt { redrawContent() } + } + ResourceLoader.getInstance(project).addListener { _, _ -> + runInEdt { redrawContent() } + } + ResourceTypesManager.getInstance(project).addListener { + runInEdt { redrawContent() } + } + subscribeToConnectionChanges() + updateContent() + } + + private fun subscribeToConnectionChanges() { + // Listen to connection state changes (for credential validation) + project.messageBus.connect(this).subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, this) + + // Listen to active connection changes + project.messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + runInEdt { updateContent() } + } + } + ) + } + + override fun settingsStateChanged(newState: ConnectionState) { + runInEdt { updateContent() } + } + + private fun updateContent() { + LOG.debug { "CloudFormationToolWindow updateContent() called" } + val connectionManager = AwsConnectionManager.getInstance(project) + when (val connectionState = connectionManager.connectionState) { + is ConnectionState.ValidConnection -> { + redrawContent() + } + is ConnectionState.ValidatingConnection -> { + LOG.debug { "Validating connection, showing validation message" } + setContent( + CenteredInfoPanel().apply { + addLine("Validating connection to AWS...") + } + ) + } + else -> { + LOG.debug { "No valid connection found (state: $connectionState), showing sign-in panel" } + setContent( + CenteredInfoPanel().apply { + addLine(message("cloudformation.explorer.sign_in")) + addDefaultActionButton(message("gettingstarted.explorer.new.setup")) { + requestCredentialsForExplorer(project) + } + } + ) + } + } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): CloudFormationToolWindow = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindowTab.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindowTab.kt new file mode 100644 index 00000000000..eb88e42ffa0 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindowTab.kt @@ -0,0 +1,16 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.explorer.ToolkitToolWindowTab +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.Component + +internal class CloudFormationToolWindowTab : ToolkitToolWindowTab { + override val tabId: String = message("cloudformation.explorer.tab.title") + + override fun createContent(project: Project): Component = + CloudFormationToolWindow.getInstance(project) +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationTreeStructure.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationTreeStructure.kt new file mode 100644 index 00000000000..a762ed4088c --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationTreeStructure.kt @@ -0,0 +1,15 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.ide.projectView.TreeStructureProvider +import com.intellij.ide.util.treeView.AbstractTreeStructureBase +import com.intellij.openapi.project.Project + +class CloudFormationTreeStructure(project: Project) : AbstractTreeStructureBase(project) { + override fun getRootElement() = CloudFormationRootNode(myProject) + override fun getProviders(): List? = null + override fun commit() {} + override fun hasSomethingToCommit(): Boolean = false +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeleteChangeSetAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeleteChangeSetAction.kt new file mode 100644 index 00000000000..a7d19d08e99 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeleteChangeSetAction.kt @@ -0,0 +1,23 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ChangeSetNode +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetDeletionWorkflow + +internal class DeleteChangeSetAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + if (Messages.showYesNoDialog(project, "Delete change set '${node.changeSetName}'?", "Delete Change Set", null) == Messages.YES) { + ChangeSetDeletionWorkflow(project).delete(node.stackName, node.changeSetName) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeployChangeSetAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeployChangeSetAction.kt new file mode 100644 index 00000000000..cf5564bc552 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeployChangeSetAction.kt @@ -0,0 +1,28 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ChangeSetNode +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.DeploymentWorkflow + +internal class DeployChangeSetAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + DeploymentWorkflow(project).deploy(node.stackName, node.changeSetName) + } + + override fun update(e: AnActionEvent) { + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() + e.presentation.isEnabled = node?.status == "CREATE_COMPLETE" + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/LoadMoreStacksAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/LoadMoreStacksAction.kt new file mode 100644 index 00000000000..ace7a875ad8 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/LoadMoreStacksAction.kt @@ -0,0 +1,16 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.resources.AwsToolkitBundle.message + +class LoadMoreStacksAction : AnAction(message("cloudformation.explorer.stacks.load_more_stacks")) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + StacksManager.getInstance(project).loadMoreStacks() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshAllAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshAllAction.kt new file mode 100644 index 00000000000..ee99b3ca2e9 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshAllAction.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +internal class RefreshAllAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + StacksManager.getInstance(project).reloadWithChangeSets() + val resourceLoader = ResourceLoader.getInstance(project) + resourceLoader.getLoadedResourceTypes().forEach { resourceLoader.refreshResources(it) } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshChangeSetsAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshChangeSetsAction.kt new file mode 100644 index 00000000000..75d44901470 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshChangeSetsAction.kt @@ -0,0 +1,20 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackChangeSetsNode +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager + +internal class RefreshChangeSetsAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + ChangeSetsManager.getInstance(project).refreshChangeSets(node.stackName) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshStacksAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshStacksAction.kt new file mode 100644 index 00000000000..977241123e3 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshStacksAction.kt @@ -0,0 +1,15 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +class RefreshStacksAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + StacksManager.getInstance(project).reloadWithChangeSets() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ResourceActions.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ResourceActions.kt new file mode 100644 index 00000000000..c5fc734b33b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ResourceActions.kt @@ -0,0 +1,305 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceNode +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceTypeNode +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceStateService +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ResourceTypeDialogUtils +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.datatransfer.StringSelection + +class AddResourceTypeAction : AnAction( + message("cloudformation.explorer.resources.add_type"), + null, + AllIcons.General.Add +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = true + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceTypesManager = ResourceTypesManager.getInstance(project) + + // Always load types (in case region changed) + resourceTypesManager.loadAvailableTypes().thenRun { + LOG.info { "loading completed, showing dialog" } + ApplicationManager.getApplication().invokeLater { + showDialog(project, resourceTypesManager) + } + } + } + + private fun showDialog(project: Project, resourceTypesManager: ResourceTypesManager) { + ResourceTypeDialogUtils.showResourceTypeSelectionDialog(project, resourceTypesManager) + } + + companion object { + private val LOG = getLogger() + } +} + +class RemoveResourceTypeAction : AnAction( + message("cloudformation.explorer.resources.remove_type"), + null, + AllIcons.General.Remove +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + // Only enable if a ResourceTypeNode is selected + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceTypeNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceTypeNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceTypesManager = ResourceTypesManager.getInstance(project) + val resourceLoader = ResourceLoader.getInstance(project) + + // Get the selected ResourceTypeNode + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + // Remove the resource type + resourceTypesManager.removeResourceType(resourceTypeNode.resourceType) + resourceLoader.clear(resourceTypeNode.resourceType) + } +} + +class RefreshResourceTypeAction : AnAction( + message("cloudformation.explorer.resources.refresh_type"), + null, + AllIcons.Actions.Refresh +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + LOG.info { "RefreshResourceTypeAction triggered" } + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + // Get the selected nodes using the correct data key + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + + // Find ResourceTypeNode in the selection + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() + + if (resourceTypeNode != null) { + LOG.info { "Reloading resource type: ${resourceTypeNode.resourceType}" } + resourceLoader.refreshResources(resourceTypeNode.resourceType) + } else { + LOG.warn { "No ResourceTypeNode found in selection" } + } + } + companion object { + private val LOG = getLogger() + } +} + +class RefreshAllLoadedResourcesAction : AnAction( + message("cloudformation.explorer.resources.refresh_all_loaded"), + null, + AllIcons.Actions.Refresh +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + // Get all currently loaded resource types and reload them + val loadedTypes = resourceLoader.getLoadedResourceTypes() + loadedTypes.forEach { resourceType -> + resourceLoader.refreshResources(resourceType) + } + } +} + +class SearchResourceAction : AnAction( + message("cloudformation.explorer.resources.search"), + null, + AllIcons.Actions.Search +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + // Only enable if a ResourceTypeNode is selected + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceTypeNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceTypeNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + // Get the selected ResourceTypeNode + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + // Prompt user for resource identifier + val identifier = Messages.showInputDialog( + project, + message("cloudformation.explorer.resources.search.prompt", resourceTypeNode.resourceType), + message("cloudformation.explorer.resources.search.title"), + AllIcons.Actions.Search + ) ?: return + + if (identifier.isBlank()) return + + // Search for the resource + resourceLoader.searchResource(resourceTypeNode.resourceType, identifier.trim()) + } +} + +class ImportResourceStateAction : AnAction( + message("cloudformation.explorer.resources.import"), + null, + AllIcons.Actions.Download +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stateService = ResourceStateService.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNodes = selectedNodes?.filterIsInstance() ?: return + + stateService.importResourceState(resourceNodes) + } +} + +class CloneResourceStateAction : AnAction( + message("cloudformation.explorer.resources.clone"), + null, + AllIcons.Vcs.Clone +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stateService = ResourceStateService.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNodes = selectedNodes?.filterIsInstance() ?: return + + stateService.cloneResourceState(resourceNodes) + } +} + +class CopyResourceIdentifierAction : AnAction( + message("cloudformation.explorer.resources.copy_identifier"), + null, + AllIcons.Actions.Copy +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.size == 1 + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + // Copy to clipboard + val selection = StringSelection(resourceNode.resourceIdentifier) + CopyPasteManager.getInstance().setContents(selection) + + // Show status message (similar to VS Code) + // Note: JetBrains doesn't have a direct equivalent to VS Code's status bar message + // but the copy operation will be visible in the clipboard + } +} + +class LoadMoreResourcesAction : AnAction( + message("cloudformation.explorer.resources.load_more"), + null, + AllIcons.General.Add +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() + + if (resourceTypeNode != null) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + val hasMore = resourceLoader.hasMore(resourceTypeNode.resourceType) + e.presentation.isVisible = hasMore + e.presentation.isEnabled = hasMore + } else { + e.presentation.isVisible = false + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + resourceLoader.loadMoreResources(resourceTypeNode.resourceType) + } +} + +class GetStackManagementInfoAction : AnAction( + message("cloudformation.explorer.resources.stack_info"), + null, + AllIcons.Actions.Properties +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.size == 1 + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stateService = ResourceStateService.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + stateService.getStackManagementInfo(resourceNode) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ViewChangeSetAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ViewChangeSetAction.kt new file mode 100644 index 00000000000..05aff054485 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ViewChangeSetAction.kt @@ -0,0 +1,43 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ChangeSetNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ChangeSetDiffPanel +import software.aws.toolkits.jetbrains.utils.notifyError + +internal class ViewChangeSetAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + CfnClientService.getInstance(project) + .describeChangeSet(DescribeChangeSetParams(node.changeSetName, node.stackName)) + .thenAccept { result -> + if (result == null) { + notifyError("CloudFormation", "Failed to describe change set", project = project) + return@thenAccept + } + runInEdt { + ChangeSetDiffPanel.show( + project = project, + stackName = node.stackName, + changeSetName = node.changeSetName, + changes = result.changes.orEmpty(), + enableDeploy = result.status == "CREATE_COMPLETE", + status = result.status, + creationTime = result.creationTime, + description = result.description, + ) + } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ChangeSetNodes.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ChangeSetNodes.kt new file mode 100644 index 00000000000..0b13abe367c --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ChangeSetNodes.kt @@ -0,0 +1,134 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ChangeSetDiffPanel +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.event.MouseEvent + +internal class StackChangeSetsNode( + nodeProject: Project, + internal val stackName: String, + private val changeSetsManager: ChangeSetsManager, +) : AbstractTreeNode(nodeProject, "changesets-$stackName"), ActionGroupOnRightClick { + + override fun actionGroupName(): String = "aws.toolkit.cloudformation.changesets.actions" + + override fun update(presentation: PresentationData) { + val changeSets = changeSetsManager.get(stackName) + val hasMore = changeSetsManager.hasMore(stackName) + val countText = if (hasMore) "(${changeSets.size}+)" else "(${changeSets.size})" + presentation.addText(message("cloudformation.explorer.stacks.change_sets"), SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.addText(" $countText", SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + if (!changeSetsManager.isLoaded(stackName)) { + changeSetsManager.fetchChangeSets(stackName) + return emptyList() + } + + val changeSets = changeSetsManager.get(stackName) + if (changeSets.isEmpty()) { + return listOf(NoChangeSetsNode(project)) + } + + val nodes = changeSets.map { changeSet -> + ChangeSetNode(project, stackName, changeSet.changeSetName, changeSet.status) + } + + return if (changeSetsManager.hasMore(stackName)) { + nodes + LoadMoreChangeSetsNode(project, stackName, changeSetsManager) + } else { + nodes + } + } +} + +internal class NoChangeSetsNode(nodeProject: Project) : AbstractTreeNode(nodeProject, "no-changesets") { + override fun update(presentation: PresentationData) { + presentation.addText("No change sets found", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class LoadMoreChangeSetsNode( + nodeProject: Project, + private val stackName: String, + private val changeSetsManager: ChangeSetsManager, +) : AbstractActionTreeNode(nodeProject, "load-more-changesets-$stackName", AllIcons.General.Add) { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.stacks.load_more"), SimpleTextAttributes.LINK_ATTRIBUTES) + presentation.setIcon(AllIcons.General.Add) + } + + override fun onDoubleClick(event: MouseEvent) { + changeSetsManager.loadMoreChangeSets(stackName) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class ChangeSetNode( + nodeProject: Project, + val stackName: String, + val changeSetName: String, + internal val status: String, +) : AbstractActionTreeNode(nodeProject, changeSetName, null), ActionGroupOnRightClick { + + override fun actionGroupName(): String = "aws.toolkit.cloudformation.changeset.actions" + + override fun onDoubleClick(event: MouseEvent) { + val clientService = CfnClientService.getInstance(project) + clientService.describeChangeSet(DescribeChangeSetParams(changeSetName, stackName)) + .thenAccept { result -> + if (result != null) { + runInEdt { + ChangeSetDiffPanel.show( + project = project, + stackName = stackName, + changeSetName = changeSetName, + changes = result.changes.orEmpty(), + enableDeploy = result.status == "CREATE_COMPLETE", + status = result.status, + creationTime = result.creationTime, + description = result.description, + ) + } + } + } + } + + override fun update(presentation: PresentationData) { + presentation.addText(changeSetName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.addText(" [$status]", SimpleTextAttributes.GRAY_ATTRIBUTES) + presentation.setIcon(getStatusIcon()) + } + + private fun getStatusIcon() = when { + status.contains("COMPLETE") && !status.contains("FAILED") -> AllIcons.General.InspectionsOK + status.contains("FAILED") -> AllIcons.General.Error + status.contains("IN_PROGRESS") || status.contains("PENDING") -> AllIcons.Process.Step_1 + else -> null + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourceTypeNode.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourceTypeNode.kt new file mode 100644 index 00000000000..26bb4d3db15 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourceTypeNode.kt @@ -0,0 +1,156 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ResourceTypeDialogUtils +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.event.MouseEvent + +internal class ResourceTypeNode( + nodeProject: Project, + val resourceType: String, + private val resourceLoader: ResourceLoader, +) : AbstractTreeNode(nodeProject, resourceType), ActionGroupOnRightClick { + + override fun actionGroupName(): String = ACTION_GROUP_NAME + + override fun isAlwaysShowPlus(): Boolean = true + + override fun update(presentation: PresentationData) { + presentation.addText(resourceType, SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.setIcon(null) // Remove any default icon + + // Only show count if resources have been loaded (dropdown was expanded) + if (resourceLoader.isLoaded(resourceType)) { + val resources = resourceLoader.getCachedResources(resourceType) + if (resources != null) { + val hasMore = resourceLoader.hasMore(resourceType) + val countText = if (hasMore) " (${resources.size}+)" else " (${resources.size})" + presentation.addText(countText, SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } + } + + override fun getChildren(): Collection> { + if (!resourceLoader.isLoaded(resourceType)) { + // Trigger loading when this node is expanded + resourceLoader.refreshResources(resourceType) + return listOf(LoadingResourcesNode(project, resourceType)) + } + + val resources = resourceLoader.getResourceIdentifiers(resourceType) + + if (resources.isEmpty()) { + return listOf(NoResourcesNode(project)) + } + + val nodes = resources.map { identifier -> + ResourceNode(project, resourceType, identifier) + } + + return if (resourceLoader.hasMore(resourceType)) { + nodes + LoadMoreResourcesNode(project, resourceType, resourceLoader) + } else { + nodes + } + } + + companion object { + private const val ACTION_GROUP_NAME = "aws.toolkit.cloudformation.resources.type.actions" + } +} + +internal class LoadingResourcesNode( + nodeProject: Project, + private val resourceType: String, +) : AbstractTreeNode(nodeProject, "loading") { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.resources.loading", resourceType), SimpleTextAttributes.GRAYED_ATTRIBUTES) + presentation.setIcon(AllIcons.Process.Step_1) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class NoResourcesNode( + nodeProject: Project, +) : AbstractTreeNode(nodeProject, "no-resources") { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.resources.no_resources"), SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class LoadMoreResourcesNode( + nodeProject: Project, + private val resourceType: String, + private val resourceLoader: ResourceLoader, +) : AbstractActionTreeNode(nodeProject, message("cloudformation.explorer.resources.load_more"), AllIcons.General.Add) { + + override fun onDoubleClick(event: MouseEvent) { + resourceLoader.loadMoreResources(resourceType) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class ResourceNode( + nodeProject: Project, + val resourceType: String, + val resourceIdentifier: String, +) : AbstractTreeNode(nodeProject, resourceIdentifier), ActionGroupOnRightClick { + override fun actionGroupName(): String = "aws.toolkit.cloudformation.resources.resource.actions" + + override fun update(presentation: PresentationData) { + presentation.addText(resourceIdentifier, SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.tooltip = "$resourceType: $resourceIdentifier" + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class AddResourceTypeNode( + nodeProject: Project, + private val resourceTypesManager: ResourceTypesManager, +) : AbstractActionTreeNode(nodeProject, message("cloudformation.explorer.resources.add_type_node"), AllIcons.General.Add) { + override fun onDoubleClick(event: MouseEvent) { + // Always load types (in case region changed) + resourceTypesManager.loadAvailableTypes().thenRun { + LOG.info { "loading completed, showing dialog" } + ApplicationManager.getApplication().invokeLater { + showDialog() + } + } + } + + private fun showDialog() { + ResourceTypeDialogUtils.showResourceTypeSelectionDialog(project, resourceTypesManager) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNode.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNode.kt new file mode 100644 index 00000000000..3f7cff0bf70 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNode.kt @@ -0,0 +1,39 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class ResourcesNode( + nodeProject: Project, + private val resourceTypesManager: ResourceTypesManager, + private val resourceLoader: ResourceLoader, +) : AbstractTreeNode(nodeProject, "resources"), ActionGroupOnRightClick { + + override fun actionGroupName(): String = "aws.toolkit.cloudformation.resources.actions" + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.resources.node"), SimpleTextAttributes.REGULAR_ATTRIBUTES) + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + val selectedTypes = resourceTypesManager.getSelectedResourceTypes() + return if (selectedTypes.isEmpty()) { + listOf(AddResourceTypeNode(project, resourceTypesManager)) + } else { + selectedTypes.map { typeName -> + ResourceTypeNode(project, typeName, resourceLoader) + } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StackNodes.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StackNodes.kt new file mode 100644 index 00000000000..7a3d5d84f06 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StackNodes.kt @@ -0,0 +1,126 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class StacksNode( + nodeProject: Project, + private val stacksManager: StacksManager, + private val changeSetsManager: ChangeSetsManager, +) : AbstractTreeNode(nodeProject, "stacks"), ActionGroupOnRightClick { + + override fun actionGroupName(): String = + if (stacksManager.hasMore()) { + "aws.toolkit.cloudformation.stacks.actions.with_more" + } else { + "aws.toolkit.cloudformation.stacks.actions" + } + + override fun update(presentation: PresentationData) { + val count = if (stacksManager.isLoaded()) { + val size = stacksManager.get().size + if (stacksManager.hasMore()) "($size+)" else "($size)" + } else { + "" + } + presentation.addText(message("cloudformation.explorer.stacks.node_name"), SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.addText(" $count", SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + if (!stacksManager.isLoaded()) { + stacksManager.reload() + return emptyList() + } + + val stacks = stacksManager.get() + + if (stacks.isEmpty()) { + return listOf(NoStacksNode(project)) + } + + val nodes = stacks.map { stack -> + StackNode(project, stack, changeSetsManager) + } + + return if (stacksManager.hasMore()) { + nodes + LoadMoreStacksNode(project, stacksManager) + } else { + nodes + } + } +} + +internal class NoStacksNode(nodeProject: Project) : AbstractTreeNode(nodeProject, "no-stacks") { + override fun update(presentation: PresentationData) { + presentation.addText("No stacks found", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class LoadMoreStacksNode( + nodeProject: Project, + private val stacksManager: StacksManager, +) : AbstractActionTreeNode(nodeProject, "load-more-stacks", AllIcons.General.Add) { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.stacks.load_more"), SimpleTextAttributes.LINK_ATTRIBUTES) + presentation.setIcon(AllIcons.General.Add) + } + + override fun onDoubleClick(event: java.awt.event.MouseEvent) { + stacksManager.loadMoreStacks() + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class StackNode( + nodeProject: Project, + val stack: StackSummary, + private val changeSetsManager: ChangeSetsManager, +) : AbstractTreeNode(nodeProject, stack), ActionGroupOnRightClick { + + override fun actionGroupName(): String = ACTION_GROUP_NAME + + override fun update(presentation: PresentationData) { + presentation.addText(stack.stackName ?: "Unknown Stack", SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.setIcon(getStackIcon()) + presentation.tooltip = "${stack.stackName ?: "Unknown"} [${stack.stackStatus ?: "Unknown"}]" + } + + private fun getStackIcon() = when { + stack.stackStatus == null -> AllIcons.Nodes.Folder + stack.stackStatus.contains("COMPLETE") && !stack.stackStatus.contains("ROLLBACK") -> AllIcons.General.InspectionsOK + stack.stackStatus.contains("FAILED") || stack.stackStatus.contains("ROLLBACK") -> AllIcons.General.Error + stack.stackStatus.contains("PROGRESS") -> AllIcons.Process.Step_1 + else -> AllIcons.Nodes.Folder + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + val stackName = stack.stackName ?: return emptyList() + return listOf(StackChangeSetsNode(project, stackName, changeSetsManager)) + } + + companion object { + const val ACTION_GROUP_NAME = "aws.toolkit.cloudformation.stack.actions" + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceCache.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceCache.kt new file mode 100644 index 00000000000..58f3d89bbf5 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceCache.kt @@ -0,0 +1,28 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import java.util.concurrent.ConcurrentHashMap + +internal data class ResourceTypeData( + val resourceIdentifiers: List, + val nextToken: String? = null, + val loaded: Boolean = false, +) + +internal class ResourceCache { + private val resourcesByType = ConcurrentHashMap() + + fun get(resourceType: String): ResourceTypeData? = resourcesByType[resourceType] + + fun put(resourceType: String, data: ResourceTypeData) { + resourcesByType[resourceType] = data + } + + fun remove(resourceType: String) = resourcesByType.remove(resourceType) + + fun keys(): Set = resourcesByType.keys.toSet() + + fun clear() = resourcesByType.clear() +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoader.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoader.kt new file mode 100644 index 00000000000..23b688acab4 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoader.kt @@ -0,0 +1,221 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceRequest +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +typealias ResourcesChangeListener = (String, List) -> Unit + +@Service(Service.Level.PROJECT) +internal class ResourceLoader( + private val project: Project, +) : Disposable { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private val cache = ResourceCache() + private val listeners = CopyOnWriteArrayList() + private val loadingTypes = ConcurrentHashMap.newKeySet() + + fun addListener(listener: ResourcesChangeListener) { + listeners.add(listener) + } + + fun getResourceIdentifiers(resourceType: String): List = + cache.get(resourceType)?.resourceIdentifiers.orEmpty() + + fun getCachedResources(resourceType: String): List? = + cache.get(resourceType)?.resourceIdentifiers + + fun hasMore(resourceType: String): Boolean = + cache.get(resourceType)?.nextToken != null + + fun isLoaded(resourceType: String): Boolean = + cache.get(resourceType)?.loaded ?: false + + fun getLoadedResourceTypes(): Set = cache.keys() + + fun refreshResources(resourceType: String) { + loadResources(resourceType, loadMore = false, useRefresh = true) + } + + fun loadMoreResources(resourceType: String) { + val currentData = cache.get(resourceType) + if (currentData?.nextToken == null) return + loadResources(resourceType, loadMore = true) + } + + fun searchResource(resourceType: String, identifier: String): CompletableFuture { + LOG.info { "Searching for resource $identifier in type $resourceType" } + + val params = SearchResourceParams(resourceType, identifier) + return clientServiceProvider().searchResource(params) + .thenApply { result -> + if (result?.found == true) { + LOG.info { "Resource $identifier found in $resourceType" } + + if (result.resource != null) { + val currentData = cache.get(resourceType) + val existingResources = currentData?.resourceIdentifiers.orEmpty() + + if (!existingResources.contains(identifier)) { + val updatedResources = existingResources + identifier + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = updatedResources, + nextToken = currentData?.nextToken, + loaded = true + ) + ) + notifyListeners(resourceType, updatedResources) + } + } else { + if (cache.get(resourceType)?.loaded != true) { + refreshResources(resourceType) + } + } + true + } else { + LOG.info { "Resource $identifier not found in $resourceType" } + false + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to search for resource $identifier in $resourceType" } + false + } + } + + fun clear(resourceType: String?) { + if (resourceType != null) { + cache.remove(resourceType) + notifyListeners(resourceType, emptyList()) + } else { + val types = cache.keys() + cache.clear() + types.forEach { type -> + notifyListeners(type, emptyList()) + } + } + } + + private fun loadResources(resourceType: String, loadMore: Boolean, useRefresh: Boolean = false) { + if (!loadMore) { + loadingTypes.add(resourceType) + } + + LOG.info { "${if (useRefresh) "Refreshing" else "Loading"} resources for type $resourceType (loadMore=$loadMore)" } + + val currentData = cache.get(resourceType) + val nextToken = if (loadMore) currentData?.nextToken else null + + if (useRefresh) { + val params = RefreshResourcesParams(resources = listOf(ResourceRequest(resourceType))) + clientServiceProvider().refreshResources(params) + .thenAccept { result -> + handleResourceResult(resourceType, result?.resources, useRefresh) + } + .exceptionally { error -> + handleResourceError(resourceType, error, loadMore, useRefresh) + } + } else { + val params = ListResourcesParams(resources = listOf(ResourceRequest(resourceType, nextToken))) + clientServiceProvider().listResources(params) + .thenAccept { result -> + handleResourceResult(resourceType, result?.resources, useRefresh) + } + .exceptionally { error -> + handleResourceError(resourceType, error, loadMore, useRefresh) + } + } + } + + private fun handleResourceResult( + resourceType: String, + resources: List?, + useRefresh: Boolean, + ) { + loadingTypes.remove(resourceType) + + if (resources != null) { + val resourceSummary = resources.firstOrNull { it.typeName == resourceType } + if (resourceSummary != null) { + // LSP server returns cumulative results, use them directly + val allResources = resourceSummary.resourceIdentifiers + + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = allResources, + nextToken = resourceSummary.nextToken, + loaded = true + ) + ) + + notifyListeners(resourceType, allResources) + LOG.info { "${if (useRefresh) "Refreshed" else "Loaded"} ${resourceSummary.resourceIdentifiers.size} resources for $resourceType" } + } else { + LOG.info { "No resources found for $resourceType" } + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = emptyList(), + nextToken = null, + loaded = true + ) + ) + notifyListeners(resourceType, emptyList()) + } + } + } + + private fun handleResourceError( + resourceType: String, + error: Throwable, + loadMore: Boolean, + useRefresh: Boolean, + ): Nothing? { + loadingTypes.remove(resourceType) + LOG.warn(error) { "Failed to ${if (useRefresh) "refresh" else "load"} resources for $resourceType" } + if (!loadMore) { + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = emptyList(), + nextToken = null, + loaded = true + ) + ) + } + return null + } + + private fun notifyListeners(resourceType: String, resources: List) { + listeners.forEach { it(resourceType, resources) } + } + + override fun dispose() { + listeners.clear() + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ResourceLoader = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceNotificationService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceNotificationService.kt new file mode 100644 index 00000000000..631a9e93802 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceNotificationService.kt @@ -0,0 +1,90 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStatePurpose +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.notifyWarn +import software.aws.toolkits.resources.message +import java.awt.datatransfer.StringSelection + +internal class ResourceNotificationService(private val project: Project) { + fun showResultNotification(successCount: Int, failureCount: Int, purpose: ResourceStatePurpose) { + val actionKey = purpose.name.lowercase() + val titleKey = "cloudformation.explorer.resources.$actionKey" + val title = message(titleKey).removeSuffix(" Resource State") + val resourcePlural = if (successCount == 1 || failureCount == 1) "" else "s" + + when { + successCount > 0 && failureCount == 0 -> { + notifyInfo( + title, + message("cloudformation.explorer.resources.$actionKey.success", successCount, resourcePlural), + project + ) + } + successCount > 0 && failureCount > 0 -> { + val successPlural = if (successCount == 1) "" else "s" + notifyWarn( + title, + message("cloudformation.explorer.resources.$actionKey.partial", successCount, successPlural, failureCount), + project + ) + } + failureCount > 0 -> { + notifyError( + title, + message("cloudformation.explorer.resources.$actionKey.failed", failureCount, resourcePlural), + project + ) + } + else -> { + notifyInfo( + title, + message("cloudformation.explorer.resources.$actionKey.none"), + project + ) + } + } + } + + fun showStackManagementInfo(result: ResourceStackManagementResult) { + val messageText = if (result.managedByStack == true) { + message("cloudformation.explorer.resources.stack_info.managed", result.stackName ?: "Unknown") + } else { + message("cloudformation.explorer.resources.stack_info.not_managed") + } + + val actions = mutableListOf() + + if (result.managedByStack == true && result.stackName != null) { + actions.add(object : AnAction(message("cloudformation.explorer.resources.stack_info.copy_name")) { + override fun actionPerformed(e: AnActionEvent) { + CopyPasteManager.getInstance().setContents(StringSelection(result.stackName)) + } + }) + + if (result.stackId != null) { + actions.add(object : AnAction(message("cloudformation.explorer.resources.stack_info.copy_arn")) { + override fun actionPerformed(e: AnActionEvent) { + CopyPasteManager.getInstance().setContents(StringSelection(result.stackId)) + } + }) + } + } + + notifyInfo( + message("cloudformation.explorer.resources.stack_info.title"), + messageText, + project, + actions + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateEditor.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateEditor.kt new file mode 100644 index 00000000000..9875f413824 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateEditor.kt @@ -0,0 +1,35 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor + +@Service(Service.Level.PROJECT) +internal class ResourceStateEditor(private val project: Project) { + fun insertAtCaret(text: String) { + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return + ApplicationManager.getApplication().invokeLater { + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(editor.caretModel.offset, text) + } + } + } + + fun getActiveDocumentUri(): String? = + FileEditorManager.getInstance(project).selectedFiles.firstOrNull()?.let { + CfnLspServerDescriptor.getInstance(project).getFileUri(it) + } + + fun getActiveEditor() = FileEditorManager.getInstance(project).selectedTextEditor + + companion object { + fun getInstance(project: Project): ResourceStateEditor = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateService.kt new file mode 100644 index 00000000000..5ebfab4736d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateService.kt @@ -0,0 +1,148 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import org.eclipse.lsp4j.TextDocumentIdentifier +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceSelection +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStatePurpose +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message + +@Service(Service.Level.PROJECT) +internal class ResourceStateService( + private val project: Project, +) { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + internal var editor = ResourceStateEditor.getInstance(project) + private val notificationService = ResourceNotificationService(project) + + fun importResourceState(resourceNodes: List) { + executeResourceStateOperation(resourceNodes, ResourceStatePurpose.IMPORT) + } + + fun cloneResourceState(resourceNodes: List) { + executeResourceStateOperation(resourceNodes, ResourceStatePurpose.CLONE) + } + + fun getStackManagementInfo(resourceNode: ResourceNode) { + clientServiceProvider().getStackManagementInfo(resourceNode.resourceIdentifier) + .thenAccept { result -> + LOG.info { "Stack management info result for ${resourceNode.resourceIdentifier}: $result" } + + ApplicationManager.getApplication().invokeLater { + if (result != null) { + notificationService.showStackManagementInfo(result) + } else { + LOG.warn { "Received null result from stack management info request" } + } + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to get stack management info for resource: ${resourceNode.resourceIdentifier}" } + ApplicationManager.getApplication().invokeLater { + notifyError( + message("cloudformation.explorer.resources.stack_info.error"), + error.message ?: "Unknown error" + ) + } + null + } + } + + private fun executeResourceStateOperation(resourceNodes: List, purpose: ResourceStatePurpose) { + if (editor.getActiveEditor() == null) { + LOG.warn { "No active editor found for resource state operation" } + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + "Open a CloudFormation template to author resource state", + project + ) + return + } + + val documentUri = editor.getActiveDocumentUri() + if (documentUri == null) { + LOG.warn { "No active file found for resource state operation" } + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + "No active file found", + project + ) + return + } + + val resourceSelections = resourceNodes.groupBy { it.resourceType }.map { (resourceType, nodes) -> + ResourceSelection(resourceType, nodes.map { it.resourceIdentifier }) + } + + LOG.info { "Executing ${purpose.name.lowercase()} operation for ${resourceNodes.size} resources" } + + val params = ResourceStateParams( + textDocument = TextDocumentIdentifier(documentUri), + resourceSelections = resourceSelections, + purpose = purpose.value + ) + + clientServiceProvider().getResourceState(params) + .thenAccept { result -> + if (result != null) { + result.warning?.let { warning -> + LOG.warn { "Warning: $warning" } + ApplicationManager.getApplication().invokeLater { + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + warning, + project + ) + } + } + + result.completionItem?.let { completionItem -> + val insertText = completionItem.insertText ?: completionItem.label + editor.insertAtCaret(insertText) + } + + val successCount = result.successfulImports.values.sumOf { it.size } + val failureCount = result.failedImports.values.sumOf { it.size } + + ApplicationManager.getApplication().invokeLater { + notificationService.showResultNotification(successCount, failureCount, purpose) + } + + if (result.successfulImports.isNotEmpty()) { + LOG.info { "Successfully processed: ${result.successfulImports}" } + } + if (result.failedImports.isNotEmpty()) { + LOG.warn { "Failed to process: ${result.failedImports}" } + } + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to execute ${purpose.name.lowercase()} operation" } + ApplicationManager.getApplication().invokeLater { + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + "Failed to ${purpose.name.lowercase()} resources: ${error.message ?: "Unknown error"}", + project + ) + } + null + } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ResourceStateService = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManager.kt new file mode 100644 index 00000000000..61d5fdc4d6d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManager.kt @@ -0,0 +1,102 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import java.util.concurrent.CompletableFuture + +@Service(Service.Level.PROJECT) +@State(name = "cfnResourceTypes", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class ResourceTypesManager( + private val project: Project, +) : PersistentStateComponent { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private var state = ResourceTypesManagerState() + private var availableTypes: List = emptyList() + private var typesLoaded: Boolean = false + private val listeners = java.util.concurrent.CopyOnWriteArrayList() + + override fun getState(): ResourceTypesManagerState = state + + override fun loadState(state: ResourceTypesManagerState) { this.state = state } + + fun addListener(listener: ResourceTypesChangeListener) { + listeners.add(listener) + } + + fun getAvailableResourceTypes(): List = availableTypes.toList() + + fun areTypesLoaded(): Boolean = typesLoaded + + fun getSelectedResourceTypes(): Set = state.selectedTypes.toSet() + + fun addResourceType(typeName: String) { + if (typeName !in state.selectedTypes) { + state.selectedTypes.add(typeName) + notifyListeners() + } + } + + fun removeResourceType(typeName: String) { + if (typeName in state.selectedTypes) { + state.selectedTypes.remove(typeName) + notifyListeners() + + // Send async request to server to clear cache + LOG.info { "Removing resource type from LSP server: $typeName" } + clientServiceProvider().removeResourceType(typeName) + .thenAccept { + LOG.info { "Successfully removed resource type: $typeName" } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to remove resource type from LSP server: $typeName" } + null + } + } + } + + fun loadAvailableTypes(): CompletableFuture { + LOG.info { "Loading available resource types" } + + return clientServiceProvider().listResourceTypes() + .thenApply { result -> + if (result != null) { + LOG.info { "Loaded ${result.resourceTypes.size} resource types" } + availableTypes = result.resourceTypes + typesLoaded = true + } else { + LOG.warn { "Failed to load resource types - null result" } + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to load resource types" } + } + } + + private fun notifyListeners() { + listeners.forEach { it() } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ResourceTypesManager = project.service() + } +} + +internal data class ResourceTypesManagerState( + var selectedTypes: MutableSet = mutableSetOf(), +) + +typealias ResourceTypesChangeListener = () -> Unit diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspClient.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspClient.kt new file mode 100644 index 00000000000..e7393e6d843 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspClient.kt @@ -0,0 +1,23 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.Lsp4jClient +import com.intellij.platform.lsp.api.LspServerNotificationsHandler +import org.eclipse.lsp4j.jsonrpc.services.JsonNotification +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.CfnDocumentManager +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.DocumentMetadata + +internal class CfnLspClient( + handler: LspServerNotificationsHandler, + private val project: Project, +) : Lsp4jClient(handler) { + + @JsonNotification("aws/documents/metadata") + fun onDocumentsMetadata(documents: Array) { + val documentManager = CfnDocumentManager.getInstance(project) + documentManager.updateDocuments(documents.toList()) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt new file mode 100644 index 00000000000..0253e17490a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt @@ -0,0 +1,262 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.ide.BrowserUtil +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.Lsp4jClient +import com.intellij.platform.lsp.api.LspServerNotificationsHandler +import com.intellij.platform.lsp.api.LspServerSupportProvider +import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor +import com.intellij.psi.codeStyle.CodeStyleSettings +import org.eclipse.lsp4j.ConfigurationItem +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.services.LanguageServer +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.lsp.NodeRuntimeResolver +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnCredentialsService +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspExtensionConfig +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspServerProtocol +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnNodePromptState +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.settings.CfnLspSettings +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.nio.file.Files +import java.nio.file.Path + +internal val CFN_SUPPORTED_EXTENSIONS = setOf("yaml", "yml", "json", "template", "cfn", "txt") + +private fun VirtualFile.isCfnTemplate(): Boolean = + extension?.lowercase() in CFN_SUPPORTED_EXTENSIONS + +// CfnLspServerSupportProvider must not be moved/renamed since we are hard-coding its class name +internal class CfnLspServerSupportProvider : LspServerSupportProvider { + override fun fileOpened( + project: Project, + file: VirtualFile, + serverStarter: LspServerSupportProvider.LspServerStarter, + ) { + if (file.isCfnTemplate()) { + serverStarter.ensureServerStarted(CfnLspServerDescriptor.getInstance(project)) + } + } +} + +class CfnLspServerDescriptor private constructor(project: Project) : + ProjectWideLspServerDescriptor(project, "AWS CloudFormation") { + + private val installer = CfnLspInstaller() + + override val lsp4jServerClass: Class = CfnLspServerProtocol::class.java + + override fun isSupportedFile(file: VirtualFile) = file.isCfnTemplate() + + override fun createLsp4jClient(handler: LspServerNotificationsHandler): Lsp4jClient = + CfnLspClient(CfnLspNotificationsHandler(handler), project) + + override fun createCommandLine(): GeneralCommandLine { + val serverPath = try { + installer.getServerPath() + } catch (e: CfnLspException) { + LOG.warn(e) { "Failed to get CloudFormation LSP server" } + notifyLspError(e) + throw e + } + + val nodePath = try { + resolveNodeRuntime() + } catch (e: Exception) { + LOG.warn(e) { "Failed to resolve Node.js runtime" } + notifyNodeError() + throw (e as? CfnLspException) ?: CfnLspException( + message("cloudformation.lsp.error.node_not_found"), + CfnLspException.ErrorCode.NODE_NOT_FOUND, + e + ) + } + + LOG.info { "Starting CloudFormation LSP: node=$nodePath, server=$serverPath" } + + return GeneralCommandLine(nodePath.toString(), serverPath.toString(), "--stdio") + .withWorkDirectory(serverPath.parent.toString()) + } + + private fun resolveNodeRuntime(): Path { + val settings = CfnLspSettings.getInstance() + + if (settings.nodeRuntimePath.isNotBlank()) { + val configured = Path.of(settings.nodeRuntimePath) + if (Files.isExecutable(configured)) return configured + LOG.warn { "Configured Node.js path is not executable: $configured, falling back to auto-detection" } + } + + return NodeRuntimeResolver.resolve() + ?: throw CfnLspException( + message("cloudformation.lsp.error.node_not_found"), + CfnLspException.ErrorCode.NODE_NOT_FOUND + ) + } + + private fun notifyLspError(e: CfnLspException) { + val content = when (e.errorCode) { + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED -> message("cloudformation.lsp.error.manifest_failed") + CfnLspException.ErrorCode.NO_COMPATIBLE_VERSION -> message("cloudformation.lsp.error.no_compatible_version") + CfnLspException.ErrorCode.DOWNLOAD_FAILED -> message("cloudformation.lsp.error.download_failed") + CfnLspException.ErrorCode.EXTRACTION_FAILED -> message("cloudformation.lsp.error.extraction_failed") + CfnLspException.ErrorCode.NODE_NOT_FOUND -> message("cloudformation.lsp.error.node_not_found") + CfnLspException.ErrorCode.HASH_VERIFICATION_FAILED -> message("cloudformation.lsp.error.hash_mismatch") + } + + notifyError( + title = message("cloudformation.lsp.error.title"), + content = content, + project = project + ) + } + + private fun notifyNodeError() { + val promptState = CfnNodePromptState.getInstance() + if (!promptState.shouldPrompt()) return + + var actionTaken = false + + val notification = Notification( + "aws.cfn.node", + message("cloudformation.lsp.error.title"), + message("cloudformation.lsp.error.node_not_found"), + NotificationType.WARNING + ) + + notification.addAction( + NotificationAction.createSimple(message("cloudformation.lsp.action.download_node")) { + BrowserUtil.browse("https://nodejs.org/en/download") + actionTaken = true + } + ) + + notification.addAction( + NotificationAction.createSimple(message("cloudformation.lsp.action.configure_node")) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, "aws.cloudformation") + actionTaken = true + } + ) + + notification.whenExpired { + if (!actionTaken) promptState.dismissTemporarily() + } + + notification.notify(project) + } + + override fun createInitializationOptions(): Any { + val settings = CfnLspSettings.getInstance() + val credentialsService = CfnCredentialsService.getInstance(project) + + return mapOf( + "handledSchemaProtocols" to listOf("file"), + "aws" to mapOf( + "clientInfo" to mapOf( + "extension" to mapOf( + "name" to CfnLspExtensionConfig.EXTENSION_NAME, + "version" to CfnLspExtensionConfig.EXTENSION_VERSION + ), + "clientId" to AwsSettings.getInstance().clientId.toString() + ), + "telemetryEnabled" to settings.isTelemetryEnabled, + "encryption" to mapOf( + "key" to credentialsService.encryptionKeyBase64, + "mode" to CfnLspExtensionConfig.ENCRYPTION_MODE + ) + ) + ) + } + + override fun getWorkspaceConfiguration(item: ConfigurationItem): Any? { + val section = item.section ?: return null + val settings = CfnLspSettings.getInstance() + + return when (section) { + "aws.cloudformation" -> buildCfnConfiguration(settings) + "editor" -> buildEditorConfiguration() + else -> null + } + } + + private fun buildCfnConfiguration(settings: CfnLspSettings): Map = mapOf( + "hover" to mapOf("enabled" to settings.isHoverEnabled), + "completion" to mapOf( + "enabled" to settings.isCompletionEnabled, + "maxCompletions" to settings.maxCompletions + ), + "diagnostics" to mapOf( + "cfnLint" to buildCfnLintConfiguration(settings), + "cfnGuard" to buildCfnGuardConfiguration(settings) + ) + ) + + private fun buildCfnLintConfiguration(settings: CfnLspSettings): Map = mapOf( + "enabled" to settings.isCfnLintEnabled, + "lintOnChange" to settings.cfnLintLintOnChange, + "delayMs" to settings.cfnLintDelayMs, + "includeExperimental" to settings.cfnLintIncludeExperimental, + "ignoreChecks" to settings.cfnLintIgnoreChecks.toStringList(), + "includeChecks" to settings.cfnLintIncludeChecks.toStringList(), + "customRules" to settings.cfnLintCustomRules.toStringList(), + "appendRules" to settings.cfnLintAppendRules.toStringList(), + "overrideSpec" to settings.cfnLintOverrideSpec.ifEmpty { null }, + "registrySchemas" to settings.cfnLintRegistrySchemas.toStringList() + ) + + private fun buildCfnGuardConfiguration(settings: CfnLspSettings): Map = mapOf( + "enabled" to settings.isCfnGuardEnabled, + "validateOnChange" to settings.cfnGuardValidateOnChange, + "enabledRulePacks" to settings.cfnGuardEnabledRulePacks.toStringList(), + "rulesFile" to settings.cfnGuardRulesFile.ifEmpty { null } + ) + + private fun buildEditorConfiguration(): Map { + val indentOptions = CodeStyleSettings.getDefaults().indentOptions + return mapOf( + "tabSize" to indentOptions.TAB_SIZE, + "insertSpaces" to !indentOptions.USE_TAB_CHARACTER, + "detectIndentation" to true + ) + } + + private fun String.toStringList(): List = + split(",").map { it.trim() }.filter { it.isNotEmpty() } + + companion object { + private val LOG = getLogger() + private val instances = mutableMapOf() + + fun getInstance(project: Project): CfnLspServerDescriptor = + instances.getOrPut(project) { CfnLspServerDescriptor(project) } + + @TestOnly + fun providerClass(): Class = CfnLspServerSupportProvider::class.java + } +} + +private class CfnLspNotificationsHandler( + private val delegate: LspServerNotificationsHandler, +) : LspServerNotificationsHandler by delegate { + override fun logMessage(params: MessageParams) { + LOG.info { "CloudFormation language server [${params.type}]: ${params.message}" } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusService.kt new file mode 100644 index 00000000000..83cc7635a56 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusService.kt @@ -0,0 +1,157 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.WindowManager +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal enum class OperationType { VALIDATION, DEPLOYMENT } + +internal data class OperationInfo( + val stackName: String, + val type: OperationType, + val changeSetName: String? = null, + val startTime: Instant = Instant.now(), + @Volatile var phase: StackActionPhase = StackActionPhase.VALIDATION_IN_PROGRESS, + @Volatile var released: Boolean = false, +) + +internal interface StatusBarHandle { + fun update(phase: StackActionPhase) + fun release() +} + +@Service(Service.Level.PROJECT) +internal class CfnOperationStatusService(private val project: Project) : Disposable { + + private val operations = ConcurrentHashMap() + private val nextId = AtomicInteger(0) + private val refCount = AtomicInteger(0) + private val scheduler = Executors.newSingleThreadScheduledExecutor() + + @Volatile private var disposeTask: ScheduledFuture<*>? = null + + fun acquire(stackName: String, type: OperationType, changeSetName: String? = null): StatusBarHandle { + synchronized(this) { + disposeTask?.cancel(false) + disposeTask = null + } + + val id = nextId.getAndIncrement() + val initialPhase = if (type == OperationType.VALIDATION) { + StackActionPhase.VALIDATION_IN_PROGRESS + } else { + StackActionPhase.DEPLOYMENT_IN_PROGRESS + } + operations[id] = OperationInfo(stackName, type, changeSetName, phase = initialPhase) + refCount.incrementAndGet() + updateWidget() + + return object : StatusBarHandle { + private var isReleased = false + + override fun update(phase: StackActionPhase) { + if (isReleased) return + operations[id]?.phase = phase + updateWidget() + } + + override fun release() { + if (isReleased) return + isReleased = true + operations[id]?.released = true + val remaining = refCount.decrementAndGet() + updateWidget() + if (remaining == 0) { + synchronized(this@CfnOperationStatusService) { + disposeTask = scheduler.schedule({ + operations.clear() + updateWidget() + }, DISPOSE_DELAY_MS, TimeUnit.MILLISECONDS) + } + } + } + } + } + + fun getActiveOperations(): List = + operations.values.filter { !it.released }.sortedByDescending { it.startTime } + + fun getAllOperations(): List = + operations.values.sortedByDescending { it.startTime } + + fun getStatusText(): String { + val unreleased = operations.values.filter { !it.released } + if (unreleased.isEmpty()) return "" + + if (unreleased.size == 1) { + val op = unreleased.first() + return when { + !op.phase.isTerminal() -> "${op.type.verb()} ${op.stackName}" + op.phase.isFailure() -> "${op.type.failedLabel()}: ${op.stackName}" + else -> "${op.type.doneLabel()} ${op.stackName}" + } + } + + val total = unreleased.size + return "$LABEL ($total)" + } + + private fun updateWidget() { + val statusBar = WindowManager.getInstance().getStatusBar(project) ?: return + statusBar.updateWidget(CfnStatusBarWidgetFactory.ID) + } + + override fun dispose() { + scheduler.shutdownNow() + operations.clear() + } + + companion object { + fun getInstance(project: Project): CfnOperationStatusService = project.service() + + private const val DISPOSE_DELAY_MS = 5000L + private const val LABEL = CfnStatusBarWidgetFactory.DISPLAY_NAME + + private fun OperationType.verb() = when (this) { + OperationType.VALIDATION -> "Validating" + OperationType.DEPLOYMENT -> "Deploying" + } + + private fun OperationType.doneLabel() = when (this) { + OperationType.VALIDATION -> "Validated" + OperationType.DEPLOYMENT -> "Deployed" + } + + private fun OperationType.failedLabel() = when (this) { + OperationType.VALIDATION -> "Validation Failed" + OperationType.DEPLOYMENT -> "Deployment Failed" + } + + internal fun StackActionPhase.isTerminal() = this in setOf( + StackActionPhase.VALIDATION_COMPLETE, + StackActionPhase.VALIDATION_FAILED, + StackActionPhase.DEPLOYMENT_COMPLETE, + StackActionPhase.DEPLOYMENT_FAILED, + StackActionPhase.DELETION_COMPLETE, + StackActionPhase.DELETION_FAILED, + ) + + internal fun StackActionPhase.isFailure() = this in setOf( + StackActionPhase.VALIDATION_FAILED, + StackActionPhase.DEPLOYMENT_FAILED, + StackActionPhase.DELETION_FAILED, + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnStatusBarWidgetFactory.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnStatusBarWidgetFactory.kt new file mode 100644 index 00000000000..067a9e73179 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnStatusBarWidgetFactory.kt @@ -0,0 +1,92 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.impl.status.widget.StatusBarEditorBasedWidgetFactory +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.SimpleListCellRenderer +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.CfnOperationStatusService.Companion.isFailure +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.CfnOperationStatusService.Companion.isTerminal +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.CfnStatusBarWidgetFactory.Companion.OPERATIONS_TITLE +import java.time.Duration +import java.time.Instant +import javax.swing.Icon + +internal class CfnStatusBarWidgetFactory : StatusBarEditorBasedWidgetFactory() { + override fun getId(): String = ID + override fun getDisplayName(): String = DISPLAY_NAME + override fun isAvailable(project: Project): Boolean = true + override fun createWidget(project: Project): StatusBarWidget = CfnStatusBarWidget(project) + override fun canBeEnabledOn(statusBar: StatusBar) = true + + companion object { + const val ID = "aws.toolkit.cloudformation.operation.status" + const val DISPLAY_NAME = "AWS CloudFormation" + const val OPERATIONS_TITLE = "AWS CloudFormation Operations" + } +} + +private class CfnStatusBarWidget( + private val project: Project, +) : StatusBarWidget, StatusBarWidget.MultipleTextValuesPresentation { + + override fun ID(): String = CfnStatusBarWidgetFactory.ID + + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun getIcon(): Icon? { + val unreleased = CfnOperationStatusService.getInstance(project).getActiveOperations() + if (unreleased.isEmpty()) return null + val hasActive = unreleased.any { !it.phase.isTerminal() } + val hasFailed = unreleased.any { it.phase.isFailure() } + return when { + hasActive -> AnimatedIcon.Default() + hasFailed -> AllIcons.General.Error + else -> AllIcons.General.InspectionsOK + } + } + + override fun getSelectedValue(): String? { + val text = CfnOperationStatusService.getInstance(project).getStatusText() + return text.ifEmpty { null } + } + + override fun getTooltipText(): String = OPERATIONS_TITLE + + override fun getPopup(): com.intellij.openapi.ui.popup.JBPopup? { + val service = CfnOperationStatusService.getInstance(project) + val operations = service.getAllOperations() + if (operations.isEmpty()) return null + + return JBPopupFactory.getInstance() + .createPopupChooserBuilder(operations) + .setTitle(OPERATIONS_TITLE) + .setRenderer( + SimpleListCellRenderer.create { label, op: OperationInfo, _ -> + label.icon = when { + !op.phase.isTerminal() -> AnimatedIcon.Default() + op.phase.isFailure() -> AllIcons.General.Error + else -> AllIcons.General.InspectionsOK + } + val elapsed = formatElapsed(op.startTime) + val changeSetInfo = op.changeSetName?.let { " • $it" }.orEmpty() + label.text = "${op.stackName} — ${op.type.name.lowercase().replaceFirstChar { it.uppercase() }}$changeSetInfo ($elapsed)" + } + ) + .setItemChosenCallback {} + .createPopup() + } + + override fun dispose() {} +} + +private fun formatElapsed(startTime: Instant): String { + val seconds = Duration.between(startTime, Instant.now()).seconds + return if (seconds < 60) "${seconds}s ago" else "${seconds / 60}m ago" +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflow.kt new file mode 100644 index 00000000000..099cbb74889 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflow.kt @@ -0,0 +1,78 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeleteChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.util.UUID +import java.util.concurrent.CompletableFuture + +internal class ChangeSetDeletionWorkflow( + project: Project, + private val clientService: CfnClientService = CfnClientService.getInstance(project), +) : PollingWorkflow(project) { + + override val operationTitle = message("cloudformation.changeset.deletion.title") + + private lateinit var stackName: String + private lateinit var changeSetName: String + + override fun fetchStatus(id: String): CompletableFuture = + clientService.getChangeSetDeletionStatus(Identifiable(id)) + + override fun handleTerminalState(status: GetStackActionStatusResult, id: String): CompletableFuture?> = + when (status.phase) { + StackActionPhase.DELETION_COMPLETE -> { + notifyInfo(operationTitle, message("cloudformation.changeset.deletion.success", changeSetName, stackName), project = project) + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + runInEdt { + StackViewWindowManager.getInstance(project) + .getTabberByName(stackName)?.removeChangeSetTab() + } + CompletableFuture.completedFuture(PollResult.Success(true)) + } + + StackActionPhase.DELETION_FAILED -> { + clientService.describeChangeSetDeletionStatus(Identifiable(id)).thenApply { details -> + notifyError( + operationTitle, + message("cloudformation.changeset.deletion.failed", changeSetName, stackName, details?.failureReason ?: "Unknown"), + project = project + ) + PollResult.Failed(details?.failureReason) + } + } + + else -> CompletableFuture.completedFuture(null) + } + + fun delete(stackName: String, changeSetName: String): CompletableFuture> { + this.stackName = stackName + this.changeSetName = changeSetName + val id = UUID.randomUUID().toString() + + return clientService.deleteChangeSet(DeleteChangeSetParams(id, changeSetName, stackName)).thenCompose { result -> + if (result == null) { + notifyError( + operationTitle, + message("cloudformation.changeset.deletion.failed", changeSetName, stackName, "Failed to start deletion"), + project = project + ) + CompletableFuture.completedFuture(PollResult.Failed("Failed to start deletion")) + } else { + notifyInfo(operationTitle, message("cloudformation.changeset.deletion.started", changeSetName, stackName), project = project) + poll(id) + } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManager.kt new file mode 100644 index 00000000000..f92cbd75fb4 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManager.kt @@ -0,0 +1,104 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ChangeSetInfo +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsParams +import java.util.concurrent.ConcurrentHashMap + +internal data class StackChangeSets( + val changeSets: List, + val nextToken: String? = null, +) + +@Service(Service.Level.PROJECT) +internal class ChangeSetsManager(private val project: Project) { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private val stackChangeSets = ConcurrentHashMap() + private val loadedStacks = ConcurrentHashMap.newKeySet() + private val listeners = mutableListOf<() -> Unit>() + + fun addListener(listener: () -> Unit) { + listeners.add(listener) + } + + fun isLoaded(stackName: String): Boolean = loadedStacks.contains(stackName) + + fun refreshChangeSets(stackName: String) { + loadedStacks.remove(stackName) + stackChangeSets.remove(stackName) + fetchChangeSets(stackName) + } + + fun fetchChangeSets(stackName: String) { + if (loadedStacks.contains(stackName)) return + + LOG.info { "Fetching change sets for $stackName" } + + clientServiceProvider().listChangeSets(ListChangeSetsParams(stackName)) + .thenAccept { result -> + if (result != null) { + LOG.info { "Loaded ${result.changeSets.size} change sets for $stackName" } + stackChangeSets[stackName] = StackChangeSets(result.changeSets, result.nextToken) + loadedStacks.add(stackName) + } else { + LOG.warn { "Received null result for change sets of $stackName" } + loadedStacks.add(stackName) + } + notifyListeners() + } + .exceptionally { error -> + LOG.warn(error) { "Failed to load change sets for $stackName" } + loadedStacks.add(stackName) + notifyListeners() + null + } + } + + fun loadMoreChangeSets(stackName: String) { + val current = stackChangeSets[stackName] ?: return + val nextToken = current.nextToken ?: return + + LOG.info { "Loading more change sets for $stackName" } + + clientServiceProvider().listChangeSets(ListChangeSetsParams(stackName, nextToken)) + .thenAccept { result -> + if (result != null) { + LOG.info { "Loaded ${result.changeSets.size} more change sets for $stackName" } + stackChangeSets[stackName] = StackChangeSets( + current.changeSets + result.changeSets, + result.nextToken + ) + } + notifyListeners() + } + .exceptionally { error -> + LOG.warn(error) { "Failed to load more change sets for $stackName" } + null + } + } + + fun get(stackName: String): List = + stackChangeSets[stackName]?.changeSets.orEmpty() + + fun hasMore(stackName: String): Boolean = + stackChangeSets[stackName]?.nextToken != null + + private fun notifyListeners() { + listeners.forEach { it() } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ChangeSetsManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflow.kt new file mode 100644 index 00000000000..1e21c1a799d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflow.kt @@ -0,0 +1,110 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateDeploymentParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewTab +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.util.UUID +import java.util.concurrent.CompletableFuture + +internal class DeploymentWorkflow( + project: Project, + private val clientService: CfnClientService = CfnClientService.getInstance(project), + private val windowManager: StackViewWindowManager = StackViewWindowManager.getInstance(project), +) : PollingWorkflow(project) { + + override val operationTitle = message("cloudformation.deployment.title") + + private lateinit var stackName: String + + override fun fetchStatus(id: String): CompletableFuture = + clientService.getDeploymentStatus(Identifiable(id)) + + override fun handleTerminalState(status: GetStackActionStatusResult, id: String): CompletableFuture?> = + when (status.phase) { + StackActionPhase.DEPLOYMENT_COMPLETE -> { + StacksManager.getInstance(project).reload() + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + if (status.state == StackActionState.SUCCESSFUL) { + notifyInfo(operationTitle, message("cloudformation.deployment.success", stackName), project = project) + CompletableFuture.completedFuture(PollResult.Success(true)) + } else { + clientService.describeDeploymentStatus(Identifiable(id)).thenApply { details -> + notifyError( + operationTitle, + message("cloudformation.deployment.failed", stackName, details?.failureReason ?: "Unknown"), + project = project + ) + PollResult.Failed(details?.failureReason) + } + } + } + + StackActionPhase.DEPLOYMENT_FAILED, StackActionPhase.VALIDATION_FAILED -> { + StacksManager.getInstance(project).reload() + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + clientService.describeDeploymentStatus(Identifiable(id)).thenApply { details -> + notifyError(operationTitle, message("cloudformation.deployment.failed", stackName, details?.failureReason ?: "Unknown"), project = project) + PollResult.Failed(details?.failureReason) + } + } + + else -> CompletableFuture.completedFuture(null) + } + + fun deploy(stackName: String, changeSetName: String): CompletableFuture> { + this.stackName = stackName + val id = UUID.randomUUID().toString() + val statusHandle = CfnOperationStatusService.getInstance(project) + .acquire(stackName, OperationType.DEPLOYMENT, changeSetName) + + // Open stack view BEFORE starting deployment to avoid EDT blocking during deployment + val tabber = try { + windowManager.getOrOpenTabber(stackName) + } catch (e: Exception) { + LOG.error(e) { "Failed to open stack view for $stackName before deployment" } + null + } + + tabber?.switchToTab(StackViewTab.EVENTS) + + return clientService.createDeployment(CreateDeploymentParams(id, changeSetName, stackName)).thenCompose { result -> + if (result == null) { + notifyError(operationTitle, message("cloudformation.deployment.failed", stackName, "Failed to start deployment"), project = project) + statusHandle.update(StackActionPhase.DEPLOYMENT_FAILED) + statusHandle.release() + CompletableFuture.completedFuture(PollResult.Failed("Failed to start deployment")) + } else { + notifyInfo(operationTitle, message("cloudformation.deployment.started", stackName), project = project) + + tabber?.restartStatusPolling() + + poll(id).whenComplete { pollResult, _ -> + val phase = when (pollResult) { + is PollResult.Success -> StackActionPhase.DEPLOYMENT_COMPLETE + else -> StackActionPhase.DEPLOYMENT_FAILED + } + statusHandle.update(phase) + statusHandle.release() + } + } + } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/LastValidationService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/LastValidationService.kt new file mode 100644 index 00000000000..04d40a63a66 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/LastValidationService.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams + +@Service(Service.Level.PROJECT) +internal class LastValidationService { + var lastParams: CreateValidationParams? = null + + companion object { + fun getInstance(project: Project): LastValidationService = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/PollingWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/PollingWorkflow.kt new file mode 100644 index 00000000000..9f66e348c6e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/PollingWorkflow.kt @@ -0,0 +1,83 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.utils.notifyError +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal sealed class PollResult { + data class Success(val value: T) : PollResult() + data class Failed(val reason: String?) : PollResult() +} + +internal abstract class PollingWorkflow( + protected val project: Project, +) { + protected abstract fun fetchStatus(id: String): CompletableFuture + protected abstract fun handleTerminalState(status: GetStackActionStatusResult, id: String): CompletableFuture?> + protected abstract val operationTitle: String + + fun poll(id: String): CompletableFuture> { + val future = CompletableFuture>() + val scheduler = Executors.newSingleThreadScheduledExecutor() + val pollCount = AtomicInteger(0) + + scheduler.scheduleWithFixedDelay( + { + if (project.isDisposed) { + future.complete(PollResult.Failed("Project closed")) + scheduler.shutdown() + return@scheduleWithFixedDelay + } + + if (pollCount.incrementAndGet() > MAX_POLL_COUNT) { + notifyError(operationTitle, "Operation timed out", project = project) + future.complete(PollResult.Failed("Timed out after ${MAX_POLL_COUNT * POLL_INTERVAL_MS / 1000}s")) + scheduler.shutdown() + return@scheduleWithFixedDelay + } + + fetchStatus(id) + .thenCompose { status -> + if (status == null) { + notifyError(operationTitle, "Failed to get operation status", project = project) + future.complete(PollResult.Failed("Failed to get status")) + scheduler.shutdown() + return@thenCompose CompletableFuture.completedFuture(null) + } + + handleTerminalState(status, id) + } + .thenAccept { result -> + @Suppress("UNCHECKED_CAST") + val typedResult = result as? PollResult + if (typedResult != null) { + future.complete(typedResult) + scheduler.shutdown() + } + } + .exceptionally { error -> + notifyError(operationTitle, error.message ?: "Unknown error", project = project) + future.complete(PollResult.Failed(error.message)) + scheduler.shutdown() + null + } + }, + POLL_INTERVAL_MS, + POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS + ) + return future + } + + companion object { + private const val POLL_INTERVAL_MS = 1000L + private const val MAX_POLL_COUNT = 3600 // 1 hour at 1s intervals + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/RerunValidateAndDeployAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/RerunValidateAndDeployAction.kt new file mode 100644 index 00000000000..fbdfe44efd3 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/RerunValidateAndDeployAction.kt @@ -0,0 +1,31 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.utils.notifyError +import java.util.UUID + +internal class RerunValidateAndDeployAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val lastParams = LastValidationService.getInstance(project).lastParams + + if (lastParams == null) { + notifyError("CloudFormation", "No previous validation to rerun", project = project) + return + } + + ValidationWorkflow(project).validate(lastParams.copy(id = UUID.randomUUID().toString())) + } + + override fun update(e: AnActionEvent) { + val project = e.project ?: return + e.presentation.isEnabled = LastValidationService.getInstance(project).lastParams != null + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManager.kt new file mode 100644 index 00000000000..270648464cc --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManager.kt @@ -0,0 +1,127 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackSummary +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo + +internal typealias StacksChangeListener = (List) -> Unit + +@Service(Service.Level.PROJECT) +internal class StacksManager(private val project: Project) : Disposable { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private var stacks: List = emptyList() + + private var nextToken: String? = null + + private var loaded = false + + private var loading = false + private val listeners = mutableListOf() + + fun addListener(listener: StacksChangeListener) { + listeners.add(listener) + } + + fun get(): List = stacks.toList() + + fun hasMore(): Boolean = nextToken != null + + fun isLoaded(): Boolean = loaded + + fun reload() { + if (loading) return + loadStacks(loadMore = false) + } + + fun reloadWithChangeSets() { + reload() + val changeSetsManager = ChangeSetsManager.getInstance(project) + stacks.forEach { stack -> + val stackName = stack.stackName ?: return@forEach + if (changeSetsManager.isLoaded(stackName)) { + changeSetsManager.refreshChangeSets(stackName) + } + } + } + + fun clear() { + stacks = emptyList() + nextToken = null + loaded = false + loading = false + notifyListeners() + } + + fun loadMoreStacks() { + if (nextToken == null || loading) return + loadStacks(loadMore = true) + } + + private fun loadStacks(loadMore: Boolean) { + loading = true + LOG.info { "Loading stacks (loadMore=$loadMore)" } + + val params = ListStacksParams( + statusToExclude = listOf("DELETE_COMPLETE"), + loadMore = loadMore + ) + + clientServiceProvider().listStacks(params) + .thenAccept { result -> + loading = false + if (result != null) { + LOG.info { "Loaded ${result.stacks.size} stacks" } + stacks = result.stacks + nextToken = result.nextToken + loaded = true + if (result.stacks.isEmpty() && !loadMore) { + notifyInfo("CloudFormation", "No stacks found in this region", project) + } + } else { + LOG.warn { "Received null result from listStacks" } + if (!loadMore) { + stacks = emptyList() + nextToken = null + loaded = true + } + } + notifyListeners() + } + .exceptionally { error -> + loading = false + LOG.warn(error) { "Failed to load stacks" } + notifyError("CloudFormation", "Failed to load stacks: ${error.message}", project) + if (!loadMore) { + stacks = emptyList() + nextToken = null + loaded = true + } + notifyListeners() + null + } + } + + private fun notifyListeners() { + listeners.forEach { it(stacks) } + } + + override fun dispose() {} + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): StacksManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidateAndDeployAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidateAndDeployAction.kt new file mode 100644 index 00000000000..224cc3714ba --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidateAndDeployAction.kt @@ -0,0 +1,176 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.vfs.VfsUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.CfnDocumentManager +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Parameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Tag +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateParameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateResource +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CFN_SUPPORTED_EXTENSIONS +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ValidateAndDeployWizard +import software.aws.toolkits.jetbrains.utils.notifyError +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit + +internal class ValidateAndDeployAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val clientService = CfnClientService.getInstance(project) + + val selectedNode = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES)?.firstOrNull() + val templateFile = e.getData(CommonDataKeys.VIRTUAL_FILE) + ?: FileEditorManager.getInstance(project).selectedEditor?.file?.takeIf { + it.extension?.lowercase() in CFN_SUPPORTED_EXTENSIONS + } + + val prefilledTemplate = templateFile?.path + val prefilledStackName = (selectedNode as? StackNode)?.stack?.stackName + + val documentManager = CfnDocumentManager.getInstance(project) + + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Loading template configuration...", true) { + private var templateParams: List = emptyList() + private var detectedCaps: List = emptyList() + private var hasArtifacts = false + private var artifactError: String? = null + private var existingParams: List? = null + private var existingTags: List? = null + private var templateResources: List = emptyList() + private var isExistingStack = false + + override fun run(indicator: ProgressIndicator) { + if (templateFile != null) { + val descriptor = CfnLspServerDescriptor.getInstance(project) + val uri = descriptor.getFileUri(templateFile) + clientService.ensureDocumentOpen(templateFile, project) + + indicator.text = "Fetching template parameters..." + try { + templateParams = clientService.getParameters(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS)?.parameters.orEmpty() + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch template parameters" } + } + + indicator.text = "Analyzing capabilities..." + try { + detectedCaps = clientService.getCapabilities(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS)?.capabilities.orEmpty() + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch capabilities" } + } + + indicator.text = "Checking artifacts..." + try { + val artifactsResult = clientService.getTemplateArtifacts(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + val artifacts = artifactsResult?.artifacts.orEmpty() + hasArtifacts = artifacts.isNotEmpty() + val templateDir = templateFile.parent?.path.orEmpty() + for (artifact in artifacts) { + val artifactPath = if (artifact.filePath.startsWith("/")) { + artifact.filePath + } else { + "$templateDir/${artifact.filePath}" + } + if (!File(artifactPath).exists()) { + artifactError = artifact.filePath + return + } + } + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to check artifacts" } + } + + indicator.text = "Fetching template resources..." + try { + templateResources = clientService.getTemplateResources(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS)?.resources.orEmpty() + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch template resources" } + } + } + + if (prefilledStackName != null) { + indicator.text = "Fetching stack details..." + try { + val stackResult = clientService.describeStack(DescribeStackParams(prefilledStackName)).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + existingParams = stackResult?.stack?.parameters + existingTags = stackResult?.stack?.tags + isExistingStack = stackResult?.stack != null + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch stack details" } + } + } + } + + override fun onSuccess() { + if (artifactError != null) { + notifyError("CloudFormation", "Artifact path does not exist: $artifactError", project = project) + return + } + + val wizard = ValidateAndDeployWizard( + project = project, + documentManager = documentManager, + prefilledTemplatePath = prefilledTemplate, + prefilledStackName = prefilledStackName, + templateParameters = templateParams, + detectedCapabilities = detectedCaps, + existingParameters = existingParams, + existingTags = existingTags, + hasArtifacts = hasArtifacts, + templateResources = templateResources, + isExistingStack = isExistingStack, + ) + + if (!wizard.showAndGet()) return + + val settings = wizard.getSettings() + val templateVFile = VfsUtil.findFileByIoFile(File(settings.templatePath), true) ?: return + + val desc = CfnLspServerDescriptor.getInstance(project) + clientService.ensureDocumentOpen(templateVFile, project) + + val params = CreateValidationParams( + id = UUID.randomUUID().toString(), + uri = desc.getFileUri(templateVFile), + stackName = settings.stackName, + parameters = settings.parameters.ifEmpty { null }, + capabilities = settings.capabilities.ifEmpty { null }, + tags = settings.tags.ifEmpty { null }, + resourcesToImport = settings.resourcesToImport, + keepChangeSet = true, + onStackFailure = settings.onStackFailure, + includeNestedStacks = settings.includeNestedStacks, + importExistingResources = settings.importExistingResources, + deploymentMode = settings.deploymentMode, + s3Bucket = settings.s3Bucket, + s3Key = settings.s3Key, + ) + + ValidationWorkflow(project).validate(params) + } + }) + } + + companion object { + private val LOG = getLogger() + private const val LSP_TIMEOUT_SECONDS = 30L + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflow.kt new file mode 100644 index 00000000000..dc1948a2908 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflow.kt @@ -0,0 +1,187 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackChange +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ChangeSetDiffPanel +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +internal sealed class ValidationResult { + data class Success( + val changes: List, + val changeSetName: String, + val details: DescribeValidationStatusResult, + ) : ValidationResult() + + data class Failed(val reason: String?) : ValidationResult() +} + +internal class ValidationWorkflow( + private val project: Project, + private val clientService: CfnClientService = CfnClientService.getInstance(project), +) { + fun validate(params: CreateValidationParams): CompletableFuture { + LastValidationService.getInstance(project).lastParams = params + val statusHandle = CfnOperationStatusService.getInstance(project) + .acquire(params.stackName, OperationType.VALIDATION) + + notifyInfo( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.started", params.stackName), + project = project + ) + + return clientService.createValidation(params).thenCompose { result -> + if (result == null) { + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", params.stackName, "Failed to start validation"), + project = project + ) + statusHandle.update(StackActionPhase.VALIDATION_FAILED) + statusHandle.release() + CompletableFuture.completedFuture(ValidationResult.Failed("Failed to start validation")) + } else { + StacksManager.getInstance(project).reload() + ChangeSetsManager.getInstance(project).refreshChangeSets(params.stackName) + pollForCompletion(result.id, result.changeSetName, params.stackName, statusHandle) + } + }.exceptionally { error -> + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", params.stackName, error.cause?.message ?: error.message ?: "Unknown error"), + project = project + ) + statusHandle.update(StackActionPhase.VALIDATION_FAILED) + statusHandle.release() + ValidationResult.Failed(error.message) + } + } + + private fun pollForCompletion( + id: String, + changeSetName: String, + stackName: String, + statusHandle: StatusBarHandle, + ): CompletableFuture { + val future = CompletableFuture() + future.whenComplete { result, _ -> + val phase = when (result) { + is ValidationResult.Success -> StackActionPhase.VALIDATION_COMPLETE + else -> StackActionPhase.VALIDATION_FAILED + } + statusHandle.update(phase) + statusHandle.release() + } + val scheduler = Executors.newSingleThreadScheduledExecutor() + + scheduler.scheduleWithFixedDelay( + { + clientService.getValidationStatus(Identifiable(id)) + .thenAccept { status -> + if (status == null) { + future.complete(ValidationResult.Failed("Failed to get validation status")) + scheduler.shutdown() + return@thenAccept + } + + when (status.phase) { + StackActionPhase.VALIDATION_COMPLETE -> { + clientService.describeValidationStatus(Identifiable(id)) + .thenAccept { details -> + if (details == null) { + future.complete(ValidationResult.Failed("Failed to get validation details")) + } else if (status.state == StackActionState.SUCCESSFUL) { + notifyInfo( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.success", stackName), + project = project + ) + // Fetch full change set details (includes property-level changes) + clientService.describeChangeSet( + DescribeChangeSetParams(changeSetName, stackName) + ).thenAccept { changeSetResult -> + val fullChanges = changeSetResult?.changes ?: status.changes.orEmpty() + runInEdt { + ChangeSetDiffPanel.show( + project = project, + stackName = stackName, + changeSetName = changeSetName, + changes = fullChanges, + enableDeploy = true, + status = changeSetResult?.status, + creationTime = changeSetResult?.creationTime, + description = changeSetResult?.description, + ) + } + } + future.complete( + ValidationResult.Success( + changes = status.changes.orEmpty(), + changeSetName = changeSetName, + details = details + ) + ) + } else { + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", stackName, details.failureReason ?: "Unknown"), + project = project + ) + future.complete(ValidationResult.Failed(details.failureReason)) + } + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + scheduler.shutdown() + } + } + + StackActionPhase.VALIDATION_FAILED -> { + clientService.describeValidationStatus(Identifiable(id)) + .thenAccept { details -> + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", stackName, details?.failureReason ?: "Unknown"), + project = project + ) + future.complete(ValidationResult.Failed(details?.failureReason)) + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + scheduler.shutdown() + } + } + + else -> {} // continue polling + } + } + .exceptionally { error -> + future.complete(ValidationResult.Failed(error.message)) + scheduler.shutdown() + null + } + }, + POLL_INTERVAL_MS, + POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS + ) + + return future + } + + companion object { + private const val POLL_INTERVAL_MS = 1000L + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt new file mode 100644 index 00000000000..41e6fac08b7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt @@ -0,0 +1,53 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackNode +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class OpenStackViewAction : AnAction(), DumbAware { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.text = message("cloudformation.stack.view") + val stackNode = getStackNode(e) + e.presentation.isEnabledAndVisible = stackNode != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stackNode = getStackNode(e) ?: return + + if (stackNode.stack.stackName == null) { + LOG.error { "Stack name is null for stack node" } + return + } + if (stackNode.stack.stackId == null) { + LOG.error { "Stack ID is null for stack node" } + return + } + val stackName = stackNode.stack.stackName + val stackId = stackNode.stack.stackId + + StackViewWindowManager.getInstance(project) + .openStack(stackName, stackId) + } + + private fun getStackNode(e: AnActionEvent): StackNode? { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + return selectedNodes?.singleOrNull() as? StackNode + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanel.kt new file mode 100644 index 00000000000..e5243835c8a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanel.kt @@ -0,0 +1,226 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ClearStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import software.aws.toolkits.jetbrains.utils.notifyInfo +import java.util.concurrent.CompletableFuture +import javax.swing.JButton +import javax.swing.JComponent +import kotlin.math.ceil + +internal class StackEventsPanel( + private val project: Project, + coordinator: StackViewCoordinator, + stackArn: String, + private val stackName: String, +) : Disposable, StackPollingListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + private var allEvents: List = emptyList() + private var currentPage: Int = 0 + private var nextToken: String? = null + private val eventsPerPage = StackEventsTableComponents.EVENTS_PER_PAGE + private var isLoading = false // Prevents rapid-fire clicks from triggering multiple concurrent operations + + private val eventTable = StackPanelLayoutBuilder.createEventsTable { operationId -> + val consoleUrl = ConsoleUrlGenerator.generateOperationUrl(stackArn, operationId) + BrowserUtil.browse(consoleUrl) + } + + private val eventCountLabel = JBLabel("0 events").apply { + foreground = UIUtil.getContextHelpForeground() + } + + internal val pageLabel = JBLabel("Page 1 of 1") + internal val prevButton = JButton("Previous").apply { + addActionListener { loadPrevPage() } + isEnabled = false + } + internal val nextButton = JButton("Next").apply { + addActionListener { loadNextPage() } + isEnabled = false + } + internal val consoleLink = IconUtils.createConsoleLinkIcon { + ConsoleUrlGenerator.generateStackEventsUrl(stackArn) + }.apply { + isVisible = false // Start hidden until successful load + } + + val component: JComponent = StackPanelLayoutBuilder.createStackTablePanel( + stackName, + eventTable, + eventCountLabel, + consoleLink, + PaginationControls(pageLabel, prevButton, nextButton) + ) + + init { + disposables.add(coordinator.addPollingListener(stackArn, this)) + loadEvents() + } + + override fun onStackPolled() { + refresh() + } + + private fun loadEvents(): CompletableFuture { + if (isLoading) return CompletableFuture.completedFuture(null) + isLoading = true + + return cfnClientService.getStackEvents(GetStackEventsParams(stackName, nextToken)) + .thenApply { result: GetStackEventsResult? -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + result?.let { handleLoadResult(it) } + } + } + .exceptionally { error: Throwable -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + handleError("Failed to load events: ${error.message}") + } + } + } + + private fun refresh() { + if (isLoading) return + isLoading = true + + // Fetch only new events and prepend them (smart updates) + cfnClientService.getStackEvents(GetStackEventsParams(stackName, refresh = true)) + .whenComplete { result: GetStackEventsResult?, error: Throwable? -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + if (error != null) { + handleError("Failed to refresh events: ${error.message}") + } else { + result?.let { handleRefreshResult(it) } + } + } + } + } + + private fun handleLoadResult(result: GetStackEventsResult) { + consoleLink.isVisible = true + allEvents = if (nextToken == null) { + result.events + } else { + allEvents + result.events + } + nextToken = result.nextToken + renderEvents() + } + + private fun handleRefreshResult(result: GetStackEventsResult) { + if (result.gapDetected == true) { + cfnClientService.getStackEvents(GetStackEventsParams(stackName)) + .whenComplete { initialResult, error -> + ApplicationManager.getApplication().invokeLater { + if (error == null && initialResult != null) { + allEvents = initialResult.events + nextToken = initialResult.nextToken + currentPage = 0 + renderEvents("Event history reloaded due to high activity") + } + } + } + } else if (result.events.isNotEmpty()) { + allEvents = result.events + allEvents + currentPage = 0 + renderEvents() + } + } + + private fun loadNextPage() { + if (isLoading) return + + val totalPages = ceil(allEvents.size.toDouble() / eventsPerPage).toInt() + val nextPageIndex = currentPage + 1 + + if (nextPageIndex < totalPages) { + currentPage = nextPageIndex + renderEvents() + } else if (nextToken != null) { + loadEvents().thenRun { + ApplicationManager.getApplication().invokeLater { + currentPage = nextPageIndex + renderEvents() + } + } + } + } + + private fun loadPrevPage() { + if (isLoading) return + + if (currentPage > 0) { + currentPage-- + renderEvents() + } else { + refresh() + } + } + + private fun renderEvents(notification: String? = null) { + StackPanelLayoutBuilder.updateEventsTable(eventTable, allEvents) + StackPanelLayoutBuilder.updateEventsTablePage(eventTable, currentPage) + updateUIComponents() + + // Show notification if provided (gap detection warning) + notification?.let { + notifyInfo("CloudFormation Events", it, this.project) + } + } + + private fun updateUIComponents() { + val hasMore = nextToken != null + eventCountLabel.text = "${allEvents.size} events${if (hasMore) " loaded" else ""}" + + val totalPages = ceil(allEvents.size.toDouble() / eventsPerPage).toInt().coerceAtLeast(1) + pageLabel.text = "Page ${currentPage + 1} of $totalPages" + + prevButton.isEnabled = currentPage > 0 && !isLoading + + val isAtLastPage = currentPage >= totalPages - 1 + nextButton.text = if (isAtLastPage && hasMore) "Load More" else "Next" + nextButton.isEnabled = (!isAtLastPage || hasMore) && !isLoading + } + + private fun handleError(message: String) { + consoleLink.isVisible = false + allEvents = emptyList() + StackPanelLayoutBuilder.updateEventsTable(eventTable, allEvents, message) + updateUIComponents() + LOG.warn { message } + } + + override fun dispose() { + if (stackName.isNotEmpty()) { + cfnClientService.clearStackEvents(ClearStackEventsParams(stackName)) + } + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsTableComponents.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsTableComponents.kt new file mode 100644 index 00000000000..d639b3854c1 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsTableComponents.kt @@ -0,0 +1,292 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.icons.AllIcons +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import java.awt.Component +import java.awt.Font +import javax.swing.Icon +import javax.swing.JTable +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer + +internal object StackEventsTableComponents { + // Column indices for events table + const val ARROW_COLUMN = 0 + const val OPERATION_COLUMN = 1 + const val TIMESTAMP_COLUMN = 2 + const val STATUS_COLUMN = 3 + const val STATUS_REASON_COLUMN = 4 + + // Pagination constants + const val EVENTS_PER_PAGE = 50 +} + +// Data class to represent a table row (either parent group or child event) +internal data class EventTableRow( + val event: StackEvent, + val isParent: Boolean, + val isVisible: Boolean = true, + val parentIndex: Int? = null, + val childCount: Int = 0, +) + +internal class ExpandableEventsTableModel : AbstractTableModel() { + private var allEvents: List = emptyList() + private val displayRows: MutableList = mutableListOf() + val expandedGroups = mutableSetOf() // Make public for renderer access + private var hasHooks = false + private val baseColumnNames = arrayOf("", "Operation ID", "Timestamp", "Status", "Status Reason") + private val hookColumnName = "Hook Invocation" + private var currentPage = 0 + private val eventsPerPage = StackEventsTableComponents.EVENTS_PER_PAGE + + fun setEvents(events: List, errorMessage: String? = null) { + allEvents = events + hasHooks = false // Reset hook detection + + currentPage = 0 // Reset to first page + rebuildDisplayRows(errorMessage) + + if (events.isEmpty()) { + fireTableStructureChanged() + return + } + + // Group events by Operation ID + val grouped = events.groupBy { it.operationId ?: "No Operation" } + + // Auto-expand first group + if (grouped.isNotEmpty()) { + val firstOperationId = grouped.keys.first() + expandedGroups.add(firstOperationId) + rebuildDisplayRows() + } + + fireTableStructureChanged() // Use structure changed since column count may change + } + + fun setCurrentPage(page: Int) { + val oldHasHooks = hasHooks + currentPage = page + rebuildDisplayRows() + + // Fire structure changed if column count changed, otherwise just data changed + if (oldHasHooks != hasHooks) { + fireTableStructureChanged() + } else { + fireTableDataChanged() + } + } + + private fun rebuildDisplayRows(errorMessage: String? = null) { + displayRows.clear() + + if (errorMessage != null) { + val errorEvent = StackEvent( + operationId = "", + resourceType = "", + resourceStatus = "", + timestamp = "", + resourceStatusReason = errorMessage + ) + displayRows.add(EventTableRow(errorEvent, isParent = false)) + return + } + + if (allEvents.isEmpty()) { + return + } + + // Calculate pagination for events + val startIndex = currentPage * eventsPerPage + val endIndex = minOf(startIndex + eventsPerPage, allEvents.size) + val pageEvents = allEvents.subList(startIndex, endIndex) + + // Check if current page has hooks + hasHooks = pageEvents.any { it.hookType != null } + + // Group events by Operation ID + val grouped = pageEvents.groupBy { it.operationId ?: "No Operation" } + + grouped.forEach { (operationId, operationEvents) -> + if (operationEvents.size == 1 && operationId == "No Operation") { + // Single event without operation ID - add directly + displayRows.add(EventTableRow(operationEvents.first(), isParent = false)) + } else { + // Add parent row + val parentEvent = operationEvents.first() + displayRows.add( + EventTableRow( + event = parentEvent, + isParent = true, + childCount = operationEvents.size + ) + ) + + // Add child rows (visible only if expanded) + val isExpanded = expandedGroups.contains(operationId) + operationEvents.forEach { event -> + displayRows.add( + EventTableRow( + event = event, + isParent = false, + isVisible = isExpanded + ) + ) + } + } + } + } + + fun toggleExpansion(row: Int) { + val visibleRows = displayRows.filter { it.isVisible } + if (row >= visibleRows.size) return + + val clickedRow = visibleRows[row] + if (!clickedRow.isParent) return + + val operationId = clickedRow.event.operationId ?: return + + if (expandedGroups.contains(operationId)) { + expandedGroups.remove(operationId) + } else { + expandedGroups.add(operationId) + } + + rebuildDisplayRows() + fireTableDataChanged() // Only data changes, not structure + } + + override fun getRowCount(): Int = displayRows.count { it.isVisible } + override fun getColumnCount(): Int = if (hasHooks) baseColumnNames.size + 1 else baseColumnNames.size + override fun getColumnName(column: Int): String = + if (column < baseColumnNames.size) { + baseColumnNames[column] + } else { + hookColumnName + } + + override fun getValueAt(rowIndex: Int, columnIndex: Int): Any { + val visibleRows = displayRows.filter { it.isVisible } + if (rowIndex >= visibleRows.size) return "" + + val row = visibleRows[rowIndex] + val event = row.event + + return when (columnIndex) { + StackEventsTableComponents.ARROW_COLUMN -> if (row.isParent) { + val isExpanded = (row.event.operationId.orEmpty()) in expandedGroups + if (isExpanded) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + } else { + "" + } + StackEventsTableComponents.OPERATION_COLUMN -> if (row.isParent) { + row.event.operationId ?: "Unknown" + } else { + " ${row.event.operationId ?: "-"}" // Indent child rows + } + StackEventsTableComponents.TIMESTAMP_COLUMN -> event.timestamp.orEmpty() + StackEventsTableComponents.STATUS_COLUMN -> event.resourceStatus.orEmpty() + StackEventsTableComponents.STATUS_REASON_COLUMN -> event.resourceStatusReason?.takeIf { it.isNotEmpty() } ?: "-" + baseColumnNames.size -> if (hasHooks) { + if (event.hookType != null) { + "${event.hookType} (${event.hookStatus ?: "Unknown"})" + } else { + "-" + } + } else { + "" + } + else -> "" + } + } + + fun getRowAt(rowIndex: Int): EventTableRow? { + val visibleRows = displayRows.filter { it.isVisible } + return if (rowIndex < visibleRows.size) visibleRows[rowIndex] else null + } + + override fun isCellEditable(rowIndex: Int, columnIndex: Int): Boolean = false +} + +internal class OperationCellRenderer : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val model = table.model as ExpandableEventsTableModel + val tableRow = model.getRowAt(row) + val component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + + if (tableRow?.isParent == true) { + val operationId = tableRow.event.operationId.orEmpty() + text = "$operationId" + font = font.deriveFont(Font.BOLD) + + // Remove click handler since table handles it + } else { + font = font.deriveFont(Font.PLAIN) + val operationId = tableRow?.event?.operationId + if (!isSelected && !operationId.isNullOrEmpty() && operationId != "-") { + foreground = JBColor.BLUE + } + } + + return component + } +} + +internal class EventsTableCellRenderer : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + + // Handle both icons and strings in arrow column + if (column == StackEventsTableComponents.ARROW_COLUMN) { + val label = JBLabel() + when (value) { + is Icon -> { + label.icon = value + label.text = null + label.horizontalAlignment = CENTER + } + is String -> { + label.text = value + label.icon = null + label.horizontalAlignment = CENTER + } + } + return label + } + + if (!isSelected && column == StackEventsTableComponents.STATUS_COLUMN) { // Status column + val status = value?.toString().orEmpty() + foreground = when { + status.contains("COMPLETE") && !status.contains("ROLLBACK") -> JBColor.GREEN + status.contains("FAILED") || status.contains("ROLLBACK") -> JBColor.RED + status.contains("PROGRESS") -> JBColor.ORANGE + else -> UIUtil.getTableForeground() + } + } else if (!isSelected) { + foreground = UIUtil.getTableForeground() + } + + return component + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanel.kt new file mode 100644 index 00000000000..d0ad55f3a68 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanel.kt @@ -0,0 +1,108 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackOutput +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import javax.swing.JComponent + +internal class StackOutputsPanel( + project: Project, + coordinator: StackViewCoordinator, + stackArn: String, + private val stackName: String, +) : Disposable, StackStatusListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + private var outputs: List = emptyList() + + // UI Components using StackPanelLayoutBuilder + internal val outputTable = StackPanelLayoutBuilder.createOutputsTable() + internal val outputCountLabel = JBLabel("0 outputs").apply { + foreground = UIUtil.getContextHelpForeground() + } + internal val consoleLink = IconUtils.createConsoleLinkIcon { + ConsoleUrlGenerator.generateStackOutputsUrl(stackArn) + }.apply { + isVisible = false // Start hidden until successful load + } + + val component: JComponent = StackPanelLayoutBuilder.createStackTablePanel( + stackName, + outputTable, + outputCountLabel, + consoleLink + ) + + init { + disposables.add(coordinator.addStatusListener(stackArn, this)) + } + + override fun onStackStatusUpdated() { + loadOutputs() + } + + private fun loadOutputs() { + cfnClientService.describeStack(DescribeStackParams(stackName)) + .thenApply { result -> result?.stack } + .whenComplete { result, error -> + ApplicationManager.getApplication().invokeLater { + if (error != null) { + handleError("Failed to load outputs for stack $stackName: ${error.message}") + } else { + result?.let { renderOutputs(it) } ?: renderEmpty() + } + } + } + } + + private fun renderOutputs(stack: StackDetail) { + outputs = stack.outputs.orEmpty() + consoleLink.isVisible = stack.stackId.isNotEmpty() + + StackPanelLayoutBuilder.updateOutputsTable(outputTable, outputs) + updateOutputCount(outputs.size) + } + + private fun renderEmpty() { + outputs = emptyList() + consoleLink.isVisible = false + StackPanelLayoutBuilder.updateOutputsTable(outputTable, outputs) + updateOutputCount(0) + } + + private fun handleError(message: String) { + outputs = emptyList() + consoleLink.isVisible = false + StackPanelLayoutBuilder.updateOutputsTable(outputTable, emptyList(), message) + updateOutputCount(0) + LOG.warn { message } + } + + private fun updateOutputCount(count: Int) { + outputCountLabel.text = "$count output${if (count != 1) "s" else ""}" + } + + override fun dispose() { + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt new file mode 100644 index 00000000000..5b4f6a46886 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt @@ -0,0 +1,211 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.WrappingTextArea +import java.awt.FlowLayout +import java.awt.Font +import java.awt.GridBagConstraints +import javax.swing.Box +import javax.swing.JComponent +import javax.swing.JPanel + +internal class StackOverviewPanel( + project: Project, + coordinator: StackViewCoordinator, + stackArn: String, + private val stackName: String, +) : Disposable, StackStatusListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + internal val consoleLink = IconUtils.createConsoleLinkIcon { + currentStackId?.let { stackId -> + ConsoleUrlGenerator.generateUrl(stackId) + } + } + + internal val stackNameValue = JBLabel("-") + internal var currentStackId: String? = null + internal val statusValue = JBLabel("Loading...") + internal val stackIdValue = WrappingTextArea("-") + internal val descriptionValue = WrappingTextArea("-") + internal val createdValue = JBLabel("-") + internal val lastUpdatedValue = JBLabel("-") + internal val statusReasonValue = WrappingTextArea("-") + + val component: JComponent = createPanel() + + init { + disposables.add(coordinator.addStatusListener(stackArn, this)) + setupStyling() + } + + private fun setupStyling() { + listOf(stackNameValue, statusValue, stackIdValue, descriptionValue, createdValue, lastUpdatedValue, statusReasonValue).forEach { label -> + label.font = label.font.deriveFont(Font.PLAIN) + } + + statusValue.border = JBUI.Borders.empty(STATUS_PADDING_VERTICAL, STATUS_PADDING_HORIZONTAL) + statusValue.horizontalAlignment = JBLabel.CENTER + } + + override fun onStackStatusUpdated() { + stackNameValue.text = stackName + renderEmpty() // Show loading state + loadStackDetails(stackName) + } + + private fun loadStackDetails(stackName: String) { + cfnClientService.describeStack(DescribeStackParams(stackName)) + .thenApply { result -> result?.stack } + .whenComplete { result, error -> + // LSP callbacks run on background threads, must switch to EDT for UI updates + ApplicationManager.getApplication().invokeLater { + if (error != null) { + LOG.warn { "Failed to load stack details for $stackName: ${error.message}" } + renderError("Failed to load stack: ${error.message}") + } else { + result?.let { + renderStack(it) + } ?: run { + LOG.warn { "No stack data received for $stackName" } + renderEmpty() + } + } + } + } + } + + private fun createPanel(): JPanel = StackPanelLayoutBuilder.createFormPanel().apply { + val gbc = GridBagConstraints().apply { + anchor = GridBagConstraints.NORTHWEST + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + } + + var row = 0 + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Stack Name", createStackNamePanel()) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Status", statusValue, fillNone = true) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Stack ID", stackIdValue) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Description", descriptionValue) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Created", createdValue) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Last Updated", lastUpdatedValue) + StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Status Reason", statusReasonValue, isLast = true) + + StackPanelLayoutBuilder.addFiller(this, gbc, row) + } + + private fun createStackNamePanel(): JPanel = JBPanel>().apply { + layout = FlowLayout(FlowLayout.LEFT, 0, 0) + add(stackNameValue) + add(Box.createHorizontalStrut(StackPanelLayoutBuilder.ICON_SPACING)) + add(consoleLink) + } + + fun renderStack(stack: StackDetail) { + stackNameValue.text = stack.stackName + updateStatusDisplay(stack.stackStatus) + consoleLink.isVisible = stack.stackId.isNotEmpty() + + updateConditionalField(stackIdValue, stack.stackId.takeIf { it.isNotEmpty() }) + updateConditionalField(descriptionValue, stack.description?.takeIf { it.isNotEmpty() }) + updateConditionalField(createdValue, stack.creationTime?.let { StackDateFormatter.formatDate(it) }) + updateConditionalField(lastUpdatedValue, stack.lastUpdatedTime?.let { StackDateFormatter.formatDate(it) }) + updateConditionalField(statusReasonValue, stack.stackStatusReason?.takeIf { it.isNotEmpty() }) + + currentStackId = stack.stackId + } + + private fun renderEmpty() { + stackNameValue.text = "Select a stack to view details" + statusValue.text = "-" + stackIdValue.text = "-" + createdValue.text = "-" + statusReasonValue.text = "-" + resetStatusStyling() + consoleLink.isVisible = false + } + + private fun renderError(message: String) { + stackNameValue.text = stackName + statusValue.text = "Error" + stackIdValue.text = "-" + createdValue.text = "-" + statusReasonValue.text = message + resetStatusStyling() + consoleLink.isVisible = false + } + + private fun updateStatusDisplay(status: String) { + statusValue.text = status + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + + if (bgColor != null) { + statusValue.isOpaque = true + statusValue.background = bgColor + statusValue.foreground = fgColor + statusValue.font = statusValue.font.deriveFont(STATUS_FONT_SIZE) + } else { + resetStatusStyling() + } + } + + private fun resetStatusStyling() { + statusValue.isOpaque = false + statusValue.foreground = JBColor.foreground() + statusValue.font = statusValue.font.deriveFont(Font.PLAIN) + } + + private fun updateConditionalField(field: JComponent, value: String?) { + if (value != null) { + when (field) { + is JBLabel -> field.text = value + is JBTextArea -> field.text = value + } + setFieldVisibility(field, true) + } else { + setFieldVisibility(field, false) + } + } + + private fun setFieldVisibility(field: JComponent, visible: Boolean) { + field.isVisible = visible + val parent = field.parent + if (parent != null) { + val fieldIndex = parent.components.indexOf(field) + if (fieldIndex > 0) { + parent.components[fieldIndex - 1].isVisible = visible + } + } + } + + override fun dispose() { + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + private const val STATUS_FONT_SIZE = 12.0f + private const val STATUS_PADDING_VERTICAL = 4 + private const val STATUS_PADDING_HORIZONTAL = 8 + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt new file mode 100644 index 00000000000..f2b074ef5f4 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt @@ -0,0 +1,267 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackOutput +import java.awt.Cursor +import java.awt.FlowLayout +import java.awt.Font +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTable +import javax.swing.ListSelectionModel +import javax.swing.table.DefaultTableModel + +internal data class PaginationControls( + val pageLabel: JComponent, + val prevButton: JButton, + val nextButton: JButton, +) + +internal object StackPanelLayoutBuilder { + internal const val ICON_SPACING = 4 + + private const val DEFAULT_PADDING = 20 + private const val FIELD_SPACING = 12 + + fun createTitleLabel(text: String): JBLabel = JBLabel(text).apply { + foreground = UIUtil.getContextHelpForeground() + font = font.deriveFont(Font.BOLD) + } + + fun createFormPanel(padding: Int = DEFAULT_PADDING): JBPanel> = JBPanel>(GridBagLayout()).apply { + border = JBUI.Borders.empty(padding) + } + + fun addLabeledField( + parent: JPanel, + gbc: GridBagConstraints, + startRow: Int, + labelText: String, + component: JComponent, + fillNone: Boolean = false, + isLast: Boolean = false, + ): Int { + // Add label + gbc.gridx = 0 + gbc.gridy = startRow + gbc.insets = JBUI.emptyInsets() + parent.add(createTitleLabel(labelText), gbc) + + // Add component + gbc.gridy = startRow + 1 + gbc.insets = if (isLast) JBUI.emptyInsets() else JBUI.insetsBottom(FIELD_SPACING) + if (fillNone) { + gbc.fill = GridBagConstraints.NONE + gbc.anchor = GridBagConstraints.WEST + } + parent.add(component, gbc) + + // Reset constraints + if (fillNone) { + gbc.fill = GridBagConstraints.HORIZONTAL + gbc.anchor = GridBagConstraints.NORTHWEST + } + + return startRow + 2 + } + + fun addFiller(parent: JPanel, gbc: GridBagConstraints, row: Int) { + gbc.gridy = row + 2 + gbc.weighty = 1.0 + gbc.fill = GridBagConstraints.BOTH + parent.add(JPanel(), gbc) + } + + fun createStackTablePanel( + stackName: String, + table: JBTable, + countLabel: JComponent? = null, + consoleLink: JComponent? = null, + paginationControls: PaginationControls? = null, + ): JComponent = panel { + row { + cell( + JBPanel>().apply { + layout = FlowLayout(FlowLayout.LEFT, ICON_SPACING, 0) + add(JBLabel("Stack: $stackName").apply { font = font.deriveFont(Font.BOLD) }) + consoleLink?.let { add(it) } + } + ) + countLabel?.let { cell(it) } + paginationControls?.let { pagination -> + cell( + JBPanel>().apply { + layout = FlowLayout(FlowLayout.RIGHT) + add(pagination.pageLabel) + add(pagination.prevButton) + add(pagination.nextButton) + } + ).align(AlignX.FILL) + } + } + row { + scrollCell(table).align(Align.FILL) + }.resizableRow() + } + + fun createOutputsTable(): JBTable = JBTable().apply { + setShowGrid(true) + autoResizeMode = JBTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + // Set initial empty state + model = object : DefaultTableModel( + arrayOf(arrayOf("No outputs found", "", "", "")), + arrayOf("Key", "Value", "Description", "Export Name") + ) { + override fun isCellEditable(row: Int, column: Int): Boolean = false + } + } + + fun updateOutputsTable(table: JBTable, outputs: List, errorMessage: String? = null) { + val columnNames = arrayOf("Key", "Value", "Description", "Export Name") + val data = when { + errorMessage != null -> arrayOf(arrayOf(errorMessage, "", "", "")) + outputs.isEmpty() -> arrayOf(arrayOf("No outputs found", "", "", "")) + else -> outputs.map { output -> + arrayOf( + output.outputKey, + output.outputValue, + output.description.orEmpty(), + output.exportName.orEmpty() + ) + }.toTypedArray() + } + + table.model = object : DefaultTableModel(data, columnNames) { + override fun isCellEditable(row: Int, column: Int): Boolean = false + } + } + + fun createEventsTable(onOperationClick: ((String) -> Unit)? = null): JBTable { + val tableModel = ExpandableEventsTableModel() + return JBTable(tableModel).apply { + setShowGrid(true) + autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + + // Arrow column renderer for icons and width + val arrowColumn = columnModel.getColumn(StackEventsTableComponents.ARROW_COLUMN) + arrowColumn.cellRenderer = EventsTableCellRenderer() + arrowColumn.preferredWidth = 30 + arrowColumn.maxWidth = 50 + arrowColumn.minWidth = 30 + + // Custom renderer for the second column (Operation) to show hyperlinks + columnModel.getColumn(StackEventsTableComponents.OPERATION_COLUMN).cellRenderer = OperationCellRenderer() + + // Status column renderer for colors + columnModel.getColumn(StackEventsTableComponents.STATUS_COLUMN).cellRenderer = EventsTableCellRenderer() + + // Click handler for expand/collapse (any column) and hyperlinks + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val row = rowAtPoint(e.point) + val col = columnAtPoint(e.point) + if (row >= 0) { + val model = model as ExpandableEventsTableModel + val tableRow = model.getRowAt(row) + + if (tableRow?.isParent == true) { + if (col == StackEventsTableComponents.OPERATION_COLUMN) { // Operation column - handle hyperlink first + val operationId = tableRow.event.operationId + if (!operationId.isNullOrEmpty()) { + onOperationClick?.invoke(operationId) + } + // Don't expand/collapse for hyperlink clicks + } else { + // Any other column - expand/collapse + model.toggleExpansion(row) + } + } else if (col == StackEventsTableComponents.OPERATION_COLUMN) { // Child row operation column + val operationId = tableRow?.event?.operationId + if (!operationId.isNullOrEmpty() && operationId != "-") { + onOperationClick?.invoke(operationId) + } + } + } + } + }) + + // Mouse motion listener for cursor changes on hyperlinks + addMouseMotionListener(object : MouseAdapter() { + override fun mouseMoved(e: MouseEvent) { + val row = rowAtPoint(e.point) + val col = columnAtPoint(e.point) + if (row >= 0 && col == StackEventsTableComponents.OPERATION_COLUMN) { + val model = model as ExpandableEventsTableModel + val tableRow = model.getRowAt(row) + val operationId = tableRow?.event?.operationId + cursor = if (!operationId.isNullOrEmpty() && operationId.trim() != "-") { + Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } else { + Cursor.getDefaultCursor() + } + } else { + cursor = Cursor.getDefaultCursor() + } + } + }) + } + } + + fun updateEventsTable(table: JBTable, events: List, errorMessage: String? = null) { + val model = table.model as ExpandableEventsTableModel + model.setEvents(events, errorMessage) + + // Reapply cell renderers after structure change + reapplyEventsTableRenderers(table) + } + + fun updateEventsTablePage(table: JBTable, page: Int) { + val model = table.model as ExpandableEventsTableModel + val oldColumnCount = table.columnCount + model.setCurrentPage(page) + + // Reapply renderers if column count changed + if (table.columnCount != oldColumnCount) { + reapplyEventsTableRenderers(table) + } + } + + private fun reapplyEventsTableRenderers(table: JBTable) { + // Arrow column renderer for icons + if (table.columnCount > StackEventsTableComponents.ARROW_COLUMN) { + val arrowColumn = table.columnModel.getColumn(StackEventsTableComponents.ARROW_COLUMN) + arrowColumn.cellRenderer = EventsTableCellRenderer() + arrowColumn.preferredWidth = 30 + arrowColumn.maxWidth = 50 + arrowColumn.minWidth = 30 + } + + // Operation column renderer for hyperlinks + if (table.columnCount > StackEventsTableComponents.OPERATION_COLUMN) { + table.columnModel.getColumn(StackEventsTableComponents.OPERATION_COLUMN).cellRenderer = OperationCellRenderer() + } + + // Status column renderer for colors + if (table.columnCount > StackEventsTableComponents.STATUS_COLUMN) { + table.columnModel.getColumn(StackEventsTableComponents.STATUS_COLUMN).cellRenderer = EventsTableCellRenderer() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanel.kt new file mode 100644 index 00000000000..e8893178904 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanel.kt @@ -0,0 +1,213 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackResourceSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import java.util.concurrent.CompletableFuture +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.table.DefaultTableModel +import kotlin.math.ceil + +internal class StackResourcesPanel( + project: Project, + coordinator: StackViewCoordinator, + private val stackArn: String, + private val stackName: String, +) : Disposable, StackPollingListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + // Resource data management + private var allResources: List = emptyList() + private var currentPage: Int = 0 + private var nextToken: String? = null + private val resourcesPerPage = 50 + private var isLoading = false // Prevents rapid-fire clicks from triggering multiple concurrent operations + + private val tableModel = DefaultTableModel( + arrayOf("Logical ID", "Physical ID", "Type", "Status"), + 0 + ) + + private val resourceTable = JBTable(tableModel).apply { + setDefaultEditor(Any::class.java, null) // Make table non-editable + autoResizeMode = JBTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + } + + internal val pageLabel = JBLabel("Page 1") + internal val prevButton = JButton("Previous").apply { addActionListener { loadPrevPage() } } + internal val nextButton = JButton("Next").apply { addActionListener { loadNextPage() } } + internal val resourceCountLabel = JBLabel("0 resources").apply { + foreground = UIUtil.getContextHelpForeground() + } + internal val consoleLink = IconUtils.createConsoleLinkIcon { + ConsoleUrlGenerator.generateStackResourcesUrl(stackArn) + }.apply { + isVisible = false // Start hidden until successful load + } + + val component: JComponent = StackPanelLayoutBuilder.createStackTablePanel( + stackName, + resourceTable, + resourceCountLabel, + consoleLink, + PaginationControls(pageLabel, prevButton, nextButton) + ) + + init { + loadResources() + disposables.add(coordinator.addPollingListener(stackArn, this)) + } + + override fun onStackPolled() { + currentPage = 0 + allResources = emptyList() + nextToken = null + isLoading = false // Reset loading flag to ensure refresh happens + loadResources() + } + + private fun loadResources(): CompletableFuture { + if (isLoading) return CompletableFuture.completedFuture(null) + isLoading = true + + val tokenToUse = nextToken + val params = GetStackResourcesParams(stackName, tokenToUse) + + return cfnClientService.getStackResources(params).whenComplete { result, error -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + if (error != null) { + handleError("Failed to load resources: ${error.message}") + } else { + result?.let { response -> + consoleLink.isVisible = true + if (tokenToUse == null) { + // Fresh load - replace all resources + allResources = response.resources + currentPage = 0 + } else { + // Pagination load - append resources + allResources = allResources + response.resources + } + nextToken = response.nextToken + updateTable() + } ?: run { + handleError("No stack data found") + } + } + } + }.thenApply { null } + } + + private fun updateTable() { + tableModel.rowCount = 0 + val startIndex = currentPage * resourcesPerPage + val endIndex = minOf(startIndex + resourcesPerPage, allResources.size) + val pageResources = allResources.subList(startIndex, endIndex) + + pageResources.forEach { resource -> + tableModel.addRow( + arrayOf( + resource.logicalResourceId, + resource.physicalResourceId ?: "N/A", + resource.resourceType, + resource.resourceStatus + ) + ) + } + + // Update pagination controls + pageLabel.text = "Page ${currentPage + 1}" + prevButton.isEnabled = currentPage > 0 && !isLoading + nextButton.isEnabled = hasMorePages() && !isLoading + + // Update resource count + val hasMore = nextToken != null + resourceCountLabel.text = "${allResources.size} resource${if (allResources.size != 1) "s" else ""}${if (hasMore) " loaded" else ""}" + + // Update button text based on whether NEXT click will need to load more from server + nextButton.text = if ((currentPage + 1) * resourcesPerPage >= allResources.size && nextToken != null) { + "Load More" + } else { + "Next" + } + } + + private fun loadNextPage() { + if (isLoading) return + + val totalPages = ceil(allResources.size.toDouble() / resourcesPerPage).toInt() + val nextPageIndex = currentPage + 1 + + if (nextPageIndex >= totalPages && nextToken == null) { + return + } + + if (nextPageIndex < totalPages) { + currentPage = nextPageIndex + updateTable() + } else { + loadResources().whenComplete { _, _ -> + ApplicationManager.getApplication().invokeLater { + if (allResources.size > nextPageIndex * resourcesPerPage) { + currentPage = nextPageIndex + updateTable() + } + } + } + } + } + + private fun loadPrevPage() { + if (isLoading) return + if (currentPage > 0) { + currentPage-- + updateTable() + } + } + + private fun hasMorePages(): Boolean = + (currentPage + 1) * resourcesPerPage < allResources.size || nextToken != null + + private fun handleError(message: String) { + consoleLink.isVisible = false + allResources = emptyList() + currentPage = 0 + nextToken = null + + tableModel.rowCount = 0 + tableModel.addRow(arrayOf(message, "", "", "")) + + // Update pagination controls for error state + pageLabel.text = "Page 1" + prevButton.isEnabled = false + nextButton.isEnabled = false + resourceCountLabel.text = "0 resources" + LOG.warn { message } + } + + override fun dispose() { + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt new file mode 100644 index 00000000000..6295a1556f6 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt @@ -0,0 +1,89 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import java.util.Timer +import java.util.TimerTask + +internal class StackStatusPoller( + project: Project, + private val stackName: String, + private val stackArn: String, // Primary identifier + private val coordinator: StackViewCoordinator, +) : Disposable { + + private val cfnClientService = CfnClientService.getInstance(project) + private var pollingTimer: Timer? = null + private var isViewVisible: Boolean = false + + fun setViewVisible(visible: Boolean) { + isViewVisible = visible + if (visible) { + start() + } else { + stop() + } + } + + fun start() { + stop() // Stop any existing polling + pollingTimer = Timer().apply { + scheduleAtFixedRate( + object : TimerTask() { + override fun run() { + if (isViewVisible) { + fetchStackData() + } + } + }, + 0, POLLING_INTERVAL_MS + ) // Poll every 5 seconds + } + } + + fun stop() { + pollingTimer?.cancel() + pollingTimer = null + } + + private fun fetchStackData() { + cfnClientService.describeStack(DescribeStackParams(stackName)) + .whenComplete { result, error -> + // Ensure coordinator updates happen on EDT + ApplicationManager.getApplication().invokeLater { + if (error != null) { + LOG.warn { "Error fetching stack data for $stackName: ${error.message}" } + } else if (result?.stack == null) { + LOG.warn { "No stack data received for $stackName" } + } else { + val stack = result.stack + coordinator.updateStackStatus(stackArn, stack.stackStatus) + + // Stop polling if stack reaches terminal state + if (!StackStatusUtils.isInTransientState(stack.stackStatus)) { + LOG.info { "Stack $stackName reached terminal state: ${stack.stackStatus}, stopping polling" } + stop() + } + } + } + } + } + + override fun dispose() { + stop() + } + + companion object { + private val LOG = getLogger() + private const val POLLING_INTERVAL_MS = 5000L + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.kt new file mode 100644 index 00000000000..a935e1a992a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.kt @@ -0,0 +1,111 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +internal interface StackStatusListener { + fun onStackStatusUpdated() +} + +internal interface StackPollingListener { + fun onStackPolled() +} + +data class StackState( + val stackName: String, + val stackArn: String, + val status: String?, + val lastUpdated: Instant, +) + +@Service(Service.Level.PROJECT) +internal class StackViewCoordinator : Disposable { + private val stackStates = ConcurrentHashMap() + private val statusListeners = ConcurrentHashMap>() + private val pollingListeners = ConcurrentHashMap>() + + fun setStack(stackArn: String, stackName: String) { + val state = StackState(stackName, stackArn, null, Instant.now()) + stackStates[stackArn] = state + notifyListeners(stackArn) + } + + fun updateStackStatus(stackArn: String, status: String) { + stackStates[stackArn]?.let { currentState -> + if (currentState.status != status) { + stackStates[stackArn] = currentState.copy(status = status, lastUpdated = Instant.now()) + notifyListeners(stackArn) + } + // Always notify polling listeners regardless of status change + notifyPollingListeners(stackArn) + } ?: LOG.warn { "Stack not found for status update: $stackArn" } + } + + fun getStackState(stackArn: String): StackState? = stackStates[stackArn] + + fun removeStack(stackArn: String) { + stackStates.remove(stackArn) + statusListeners.remove(stackArn) + pollingListeners.remove(stackArn) + } + + fun addStatusListener(stackArn: String, listener: StackStatusListener): Disposable { + statusListeners.computeIfAbsent(stackArn) { CopyOnWriteArrayList() }.add(listener) + + // Immediately notify new listener of current state + stackStates[stackArn]?.let { + listener.onStackStatusUpdated() + } + + return Disposable { + statusListeners[stackArn]?.remove(listener) + if (statusListeners[stackArn]?.isEmpty() == true) { + statusListeners.remove(stackArn) + } + } + } + + fun addPollingListener(stackArn: String, listener: StackPollingListener): Disposable { + pollingListeners.computeIfAbsent(stackArn) { CopyOnWriteArrayList() }.add(listener) + + return Disposable { + pollingListeners[stackArn]?.remove(listener) + if (pollingListeners[stackArn]?.isEmpty() == true) { + pollingListeners.remove(stackArn) + } + } + } + + private fun notifyListeners(stackArn: String) { + statusListeners[stackArn]?.forEach { + it.onStackStatusUpdated() + } + } + + private fun notifyPollingListeners(stackArn: String) { + pollingListeners[stackArn]?.forEach { + it.onStackPolled() + } + } + + override fun dispose() { + stackStates.clear() + statusListeners.clear() + pollingListeners.clear() + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): StackViewCoordinator = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt new file mode 100644 index 00000000000..c0d294974a6 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt @@ -0,0 +1,96 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBTabbedPane +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +enum class StackViewTab(val index: Int, val title: String) { + OVERVIEW(0, "Overview"), + EVENTS(1, "Events"), + RESOURCES(2, "Resources"), + OUTPUTS(3, "Outputs"), + CHANGE_SET(4, "Change Set"), +} + +internal class StackViewPanelTabber( + project: Project, + internal val stackName: String, + private val stackArn: String, +) : Disposable { + + private val coordinator = StackViewCoordinator.getInstance(project) + private val poller = StackStatusPoller(project, stackName, stackArn, coordinator) + private val overviewPanel = StackOverviewPanel(project, coordinator, stackArn, stackName) + private val resourcesPanel = StackResourcesPanel(project, coordinator, stackArn, stackName) + private val outputsPanel = StackOutputsPanel(project, coordinator, stackArn, stackName) + private val eventsPanel = StackEventsPanel(project, coordinator, stackArn, stackName) + + private val tabbedPane = JBTabbedPane().apply { + addTab(StackViewTab.OVERVIEW.title, createOverviewPanel()) + addTab(StackViewTab.EVENTS.title, createEventsPanel()) + addTab(StackViewTab.RESOURCES.title, createResourcesPanel()) + addTab(StackViewTab.OUTPUTS.title, createOutputsPanel()) + selectedIndex = StackViewTab.OVERVIEW.index + } + + private val changeSetTabIndex: Int + get() = if (tabbedPane.tabCount > StackViewTab.CHANGE_SET.index) StackViewTab.CHANGE_SET.index else -1 + + fun updateChangeSetTab(title: String, component: JComponent, tooltip: String? = null) { + if (changeSetTabIndex >= 0) { + tabbedPane.setComponentAt(StackViewTab.CHANGE_SET.index, component) + tabbedPane.setTitleAt(StackViewTab.CHANGE_SET.index, title) + tabbedPane.setToolTipTextAt(StackViewTab.CHANGE_SET.index, tooltip) + } else { + tabbedPane.insertTab(title, null, component, tooltip, StackViewTab.CHANGE_SET.index) + } + tabbedPane.selectedIndex = StackViewTab.CHANGE_SET.index + } + + fun removeChangeSetTab() { + if (changeSetTabIndex >= 0) tabbedPane.removeTabAt(StackViewTab.CHANGE_SET.index) + } + + fun switchToTab(tab: StackViewTab) { + tabbedPane.selectedIndex = tab.index + } + + fun restartStatusPolling() { + poller.start() + } + + private val mainPanel = JBPanel>(BorderLayout()).apply { + add(tabbedPane, BorderLayout.CENTER) + } + + private fun createOverviewPanel(): JComponent = overviewPanel.component + + private fun createResourcesPanel(): JComponent = resourcesPanel.component + + private fun createEventsPanel(): JComponent = eventsPanel.component + + private fun createOutputsPanel(): JComponent = outputsPanel.component + + fun start() { + coordinator.setStack(stackArn, stackName) + poller.setViewVisible(true) + } + + fun getComponent(): JPanel = mainPanel + + override fun dispose() { + poller.dispose() + overviewPanel.dispose() + resourcesPanel.dispose() + outputsPanel.dispose() + eventsPanel.dispose() + coordinator.removeStack(stackArn) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewToolWindowFactory.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewToolWindowFactory.kt new file mode 100644 index 00000000000..b019803903b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewToolWindowFactory.kt @@ -0,0 +1,25 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import software.aws.toolkits.resources.message + +internal class StackViewToolWindowFactory : ToolWindowFactory { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + runInEdt { + toolWindow.installWatcher(toolWindow.contentManager) + } + // Don't create any initial content - tabs will be created when stacks are opened + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("cloudformation.lsp.stack.view") + } + + override fun shouldBeAvailable(project: Project): Boolean = false +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt new file mode 100644 index 00000000000..7d57f321444 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt @@ -0,0 +1,188 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.content.Content +import com.intellij.ui.content.ContentFactory +import com.intellij.ui.content.ContentManager +import com.intellij.ui.content.ContentManagerEvent +import com.intellij.ui.content.ContentManagerListener +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.PROJECT) +internal class StackViewWindowManager(private val project: Project) { + + private val activeStacks = ConcurrentHashMap() + private var listenerRegistered = false + + fun getTabberByName(stackName: String): StackViewPanelTabber? = + activeStacks.values.firstOrNull { it.stackName == stackName } + + fun getOrOpenTabber(stackName: String): StackViewPanelTabber? { + var tabber = getTabberByName(stackName) + if (tabber == null) { + try { + val stackResult = CfnClientService.getInstance(project) + .describeStack(DescribeStackParams(stackName)).get() + val stackId = stackResult?.stack?.stackId + + if (stackId == null) { + LOG.error { "Failed to get stackId for stack: $stackName" } + return null + } + + openStack(stackName, stackId) + tabber = getTabberByName(stackName) + } catch (e: Exception) { + LOG.error(e) { "Failed to ensure stack view is open for $stackName" } + return null + } + } + return tabber + } + + fun openStack(stackName: String, stackId: String) { + LOG.info { "openStack called for stackName: $stackName, stackId: $stackId" } + + val toolWindowManager = ToolWindowManager.getInstance(project) + val toolWindow = toolWindowManager.getToolWindow(TOOL_WINDOW_ID) + + if (toolWindow == null) { + LOG.error { "Tool window '$TOOL_WINDOW_ID' not found" } + return + } + + // Make tool window available if it's not already + if (!toolWindow.isAvailable) { + toolWindow.setAvailable(true, null) + } + + val contentManager = toolWindow.contentManager + + // Check if tab already exists + val existingContent = contentManager.contents.find { + it.getUserData(STACK_ARN_KEY) == stackId + } + + if (existingContent != null) { + contentManager.setSelectedContent(existingContent) + runInEdt { + toolWindow.show() + toolWindow.activate(null, true) + } + return + } + + if (contentManager.contentCount >= MAX_TABS) { + removeOldestTab() + } + + // Create new stack view + val stackView = try { + StackViewPanelTabber(project, stackName, stackId) + } catch (e: Exception) { + LOG.error(e) { "Failed to create StackDetailView" } + return + } + + val content = ContentFactory.getInstance().createContent( + stackView.getComponent(), + stackName, // Tab title is stack name, key is stack arn + true + ) + content.putUserData(STACK_ARN_KEY, stackId) + + setupContentManagerListener(contentManager) + + contentManager.addContent(content) + contentManager.setSelectedContent(content) + + activeStacks[stackId] = stackView + + setupStackStatusListener(stackId, stackName, content) + + stackView.start() + + runInEdt { + toolWindow.show() + toolWindow.activate(null, true) + } + } + + private fun setupContentManagerListener(contentManager: ContentManager) { + if (!listenerRegistered) { + contentManager.addContentManagerListener(object : ContentManagerListener { + override fun contentRemoved(event: ContentManagerEvent) { + val removedContent = event.content + val stackArn = removedContent.getUserData(STACK_ARN_KEY) + if (stackArn != null) { + LOG.info { "Tab closed by user, disposing resources for stack: $stackArn" } + activeStacks[stackArn]?.dispose() + activeStacks.remove(stackArn) + } + } + }) + listenerRegistered = true + } + } + + private fun setupStackStatusListener(stackId: String, stackName: String, content: Content) { + val coordinator = StackViewCoordinator.getInstance(project) + coordinator.addStatusListener( + stackId, + object : StackStatusListener { + override fun onStackStatusUpdated() { + val stackState = coordinator.getStackState(stackId) + val status = stackState?.status + LOG.info { "Updating tab title for stack: $stackId, status: $status" } + runInEdt { + val displayName = if (status != null) { + "$stackName [$status]" + } else { + stackName + } + content.displayName = displayName + } + } + } + ) + } + + private fun removeOldestTab() { + val toolWindowManager = ToolWindowManager.getInstance(project) + val toolWindow = toolWindowManager.getToolWindow(TOOL_WINDOW_ID) ?: return + val contentManager = toolWindow.contentManager + + if (contentManager.contentCount > 0) { + val oldestContent = contentManager.contents.first() + val stackArn = oldestContent.getUserData(STACK_ARN_KEY) + stackArn?.let { + activeStacks[it]?.dispose() + activeStacks.remove(it) + } + contentManager.removeContent(oldestContent, true) + } + } + + companion object { + private val LOG = getLogger() + private val STACK_ARN_KEY = Key.create("STACK_ARN") + + private const val MAX_TABS = 10 + private const val TOOL_WINDOW_ID = "cloudformation.lsp.stack.view" + + fun getInstance(project: Project): StackViewWindowManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt new file mode 100644 index 00000000000..c6e0273edb7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt @@ -0,0 +1,39 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.JBColor +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import java.awt.Color +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +internal object StackStatusUtils { + fun getStatusColors(status: String): Pair = when { + status.contains("COMPLETE") && !status.contains("ROLLBACK") -> + JBColor.GREEN to JBColor.BLACK + status.contains("FAILED") || status.contains("ROLLBACK") -> + JBColor.RED to JBColor.BLACK + status.contains("PROGRESS") -> + JBColor.YELLOW to JBColor.BLACK + else -> null to null + } + + fun isInTransientState(status: String): Boolean = status.contains("_IN_PROGRESS") +} + +internal object StackDateFormatter { + private val LOG = getLogger() + private val dateFormatter = DateTimeFormatter.ofPattern("d/M/yyyy, h:mm:ss a") + + fun formatDate(dateString: String): String? = try { + val instant = Instant.parse(dateString) + instant.atZone(ZoneId.systemDefault()).format(dateFormatter) + } catch (e: Exception) { + LOG.warn(e) { "Failed to parse date string: $dateString" } + null + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDiffPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDiffPanel.kt new file mode 100644 index 00000000000..dcd84f25cc2 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDiffPanel.kt @@ -0,0 +1,532 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import com.intellij.diff.DiffContentFactory +import com.intellij.diff.DiffManager +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.icons.AllIcons +import com.intellij.json.JsonFileType +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.ui.Splitter +import com.intellij.ui.JBColor +import com.intellij.ui.PopupHandler +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChange +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChangeDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackChange +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetDeletionWorkflow +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.DeploymentWorkflow +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Font +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.BoxLayout +import javax.swing.ListSelectionModel +import javax.swing.event.ListSelectionEvent +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer + +private const val DRIFT_WARNING = "\u26A0\uFE0F" + +internal class ChangeSetDiffPanel( + private val project: Project, + private val stackName: String, + private val changeSetName: String, + changes: List, + private val enableDeploy: Boolean, + private val status: String? = null, + private val creationTime: String? = null, + private val description: String? = null, +) : SimpleToolWindowPanel(false, true) { + + private val resourceChanges = changes.mapNotNull { it.resourceChange } + private val hasDrift = resourceChanges.any { it.hasDrift() } + private val resourceTable = JBTable(ResourceTableModel(resourceChanges, hasDrift)).apply { + setSelectionMode(ListSelectionModel.SINGLE_SELECTION) + setShowGrid(false) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) showResourceDiff() + } + }) + addMouseListener(object : PopupHandler() { + override fun invokePopup(comp: Component, x: Int, y: Int) { + val row = rowAtPoint(Point(x, y)) + if (row < 0) return + setRowSelectionInterval(row, row) + val menu = ActionManager.getInstance().createActionPopupMenu( + "ChangeSetDiffContext", + DefaultActionGroup( + object : AnAction("View Diff", null, AllIcons.Actions.Diff) { + override fun actionPerformed(e: AnActionEvent) { + showResourceDiff() + } + } + ) + ) + menu.component.show(comp, x, y) + } + }) + } + + private val hasAnyDetails = resourceChanges.any { !it.details.isNullOrEmpty() } + private val detailTable = JBTable().apply { setShowGrid(false) } + private val detailPanel = JBPanel>(BorderLayout()) + + init { + if (hasDrift) applyDriftRenderer() + + resourceTable.selectionModel.addListSelectionListener { e: ListSelectionEvent -> + if (!e.valueIsAdjusting) onResourceSelected() + } + + val headerPanel = buildHeaderPanel() + + val tableContent = if (hasAnyDetails) { + Splitter(true, 0.55f).apply { + firstComponent = JBScrollPane(resourceTable) + secondComponent = detailPanel + } + } else { + JBScrollPane(resourceTable) + } + + val mainContent = JBPanel>(BorderLayout()).apply { + add(headerPanel, BorderLayout.NORTH) + add(tableContent, BorderLayout.CENTER) + } + + toolbar = createToolbar() + setContent(mainContent) + } + + private fun buildHeaderPanel(): JBPanel> = JBPanel>().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = JBUI.Borders.empty(6, 8) + + val parts = buildList { + add(changeSetName) + status?.let { add("Status: $it") } + creationTime?.let { add("Created: $it") } + description?.let { add(it) } + } + val label = JBLabel(parts.joinToString(" | ")).apply { + foreground = JBColor.GRAY + } + add(label) + add(javax.swing.JSeparator().apply { border = JBUI.Borders.emptyTop(4) }) + } + + private fun applyDriftRenderer() { + val driftColIndex = resourceTable.columnCount - 1 + resourceTable.columnModel.getColumn(driftColIndex).cellRenderer = WarningCellRenderer() + } + + private fun onResourceSelected() { + if (!hasAnyDetails) return + val row = resourceTable.selectedRow + if (row < 0 || row >= resourceChanges.size) return + val rc = resourceChanges[row] + val details = rc.details + + detailPanel.removeAll() + if (!details.isNullOrEmpty()) { + val hasDetailDrift = details.any { it.target?.drift != null || it.target?.liveResourceDrift != null } + detailTable.model = DetailTableModel(details, hasDetailDrift) + if (hasDetailDrift) applyDetailDriftRenderer() + val title = JBLabel("Property-level changes").apply { + font = font.deriveFont(Font.BOLD) + border = JBUI.Borders.empty(4, 6) + } + detailPanel.add(title, BorderLayout.NORTH) + detailPanel.add(JBScrollPane(detailTable), BorderLayout.CENTER) + } + detailPanel.revalidate() + detailPanel.repaint() + } + + private fun applyDetailDriftRenderer() { + val colCount = detailTable.columnCount + val renderer = WarningCellRenderer() + detailTable.columnModel.getColumn(colCount - 2).cellRenderer = renderer + detailTable.columnModel.getColumn(colCount - 1).cellRenderer = renderer + } + + private fun showResourceDiff() { + val row = resourceTable.selectedRow + if (row < 0 || row >= resourceChanges.size) return + val rc = resourceChanges[row] + val before = formatJson(rc.beforeContext.orEmpty()) + val after = formatJson(rc.afterContext.orEmpty()) + if (before.isEmpty() && after.isEmpty()) return + + val annotatedBefore = annotateDriftInJson(rc, before) + val factory = DiffContentFactory.getInstance() + val title = "${rc.logicalResourceId ?: "Resource"} — $stackName" + DiffManager.getInstance().showDiff( + project, + SimpleDiffRequest( + title, + factory.create(annotatedBefore, JsonFileType.INSTANCE), + factory.create(after, JsonFileType.INSTANCE), + "Before", + "After" + ) + ) + } + + private fun formatJson(raw: String): String { + if (raw.isBlank()) return "" + return try { + GsonBuilder().setPrettyPrinting().create().toJson(JsonParser.parseString(raw)) + } catch (_: Exception) { + raw + } + } + + private fun showAllResourcesDiff() { + val beforeData = mutableMapOf() + val afterData = mutableMapOf() + + for (rc in resourceChanges) { + val id = rc.logicalResourceId ?: continue + + if (rc.action != "Add" || rc.resourceDriftStatus == "DELETED") { + beforeData[id] = parseJsonOrEmpty(rc.beforeContext) + } + if (rc.action != "Remove") { + afterData[id] = parseJsonOrEmpty(rc.afterContext) + } + + if (rc.beforeContext == null && rc.afterContext == null) { + rc.details?.forEach { detail -> + val target = detail.target ?: return@forEach + val name = target.name ?: return@forEach + if (rc.action != "Add") { + @Suppress("UNCHECKED_CAST") + (beforeData.getOrPut(id) { mutableMapOf() } as MutableMap)[name] = + target.beforeValue ?: "" + } + if (rc.action != "Remove") { + @Suppress("UNCHECKED_CAST") + (afterData.getOrPut(id) { mutableMapOf() } as MutableMap)[name] = + target.afterValue ?: "" + } + } + } + } + + val gson = GsonBuilder().setPrettyPrinting().create() + val beforeJson = gson.toJson(beforeData) + val afterJson = gson.toJson(afterData) + + val annotatedBefore = annotateDriftInJsonAll(resourceChanges, beforeJson) + val factory = DiffContentFactory.getInstance() + DiffManager.getInstance().showDiff( + project, + SimpleDiffRequest( + "$stackName: Before \u2194 After", + factory.create(annotatedBefore, JsonFileType.INSTANCE), + factory.create(afterJson, JsonFileType.INSTANCE), + "Before", + "After" + ) + ) + } + + private fun parseJsonOrEmpty(raw: String?): Any { + if (raw.isNullOrBlank()) return emptyMap() + return try { + GsonBuilder().create().fromJson(raw, Map::class.java) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } + } + + private fun createToolbar() = ActionManager.getInstance().createActionToolbar( + "ChangeSetDiff", + DefaultActionGroup().apply { + if (enableDeploy) { + add( + object : AnAction( + "Execute Change Set", + "Execute this change set", + AllIcons.Actions.Execute, + ) { + override fun actionPerformed(e: AnActionEvent) { + DeploymentWorkflow(project).deploy(stackName, changeSetName) + } + } + ) + } + add( + object : AnAction( + "View Diff", + "View side-by-side diff of all changes", + AllIcons.Actions.Diff, + ) { + override fun actionPerformed(e: AnActionEvent) { + showAllResourcesDiff() + } + } + ) + add( + object : AnAction( + "Delete Change Set", + "Delete this change set", + AllIcons.Actions.GC, + ) { + override fun actionPerformed(e: AnActionEvent) { + val msg = "Delete change set '$changeSetName'?" + if (Messages.showYesNoDialog(project, msg, "Delete Change Set", null) == Messages.YES) { + ChangeSetDeletionWorkflow(project).delete(stackName, changeSetName) + } + } + } + ) + }, + true + ).apply { + targetComponent = this@ChangeSetDiffPanel + }.component + + companion object { + private val LOG = getLogger() + + fun show( + project: Project, + stackName: String, + changeSetName: String, + changes: List, + enableDeploy: Boolean, + status: String? = null, + creationTime: String? = null, + description: String? = null, + ) { + val panel = ChangeSetDiffPanel( + project, + stackName, + changeSetName, + changes, + enableDeploy, + status, + creationTime, + description, + ) + + try { + val tabber = StackViewWindowManager.getInstance(project).getOrOpenTabber(stackName) + if (tabber != null) { + tabber.updateChangeSetTab("Change set", panel, tooltip = changeSetName) + } else { + LOG.error { "Failed to get tabber for stack: $stackName" } + } + } catch (e: Exception) { + LOG.error(e) { "Exception while getting or opening stack view for $stackName" } + } + } + } +} + +internal fun annotateDriftInJson(rc: ResourceChange, beforeJson: String): String { + val driftByPath = mutableMapOf() + + if (rc.resourceDriftStatus == "DELETED") return "// $DRIFT_WARNING Resource deleted out-of-band\n$beforeJson" + + rc.details?.forEach { detail -> + val target = detail.target ?: return@forEach + val drift = target.drift ?: target.liveResourceDrift ?: return@forEach + val path = target.path ?: return@forEach + if (drift.actualValue == null) return@forEach + driftByPath[path] = drift.actualValue + } + if (driftByPath.isEmpty()) return beforeJson + + val lines = beforeJson.lines().toMutableList() + for ((path, actualValue) in driftByPath) { + val lineIndex = findPropertyLine(lines, path) + if (lineIndex >= 0) { + lines[lineIndex] = "${lines[lineIndex]} \u2190 $DRIFT_WARNING Drifted (Live AWS: $actualValue)" + } + } + return lines.joinToString("\n") +} + +private fun annotateDriftInJsonAll(resourceChanges: List, beforeJson: String): String { + val lines = beforeJson.lines().toMutableList() + for (rc in resourceChanges) { + rc.details?.forEach { detail -> + val target = detail.target ?: return@forEach + val drift = target.drift ?: target.liveResourceDrift ?: return@forEach + val path = target.path ?: return@forEach + if (drift.actualValue == null) return@forEach + + val id = rc.logicalResourceId ?: return@forEach + val resourceLine = lines.indexOfFirst { it.contains("\"$id\"") } + if (resourceLine < 0) return@forEach + + if (rc.resourceDriftStatus == "DELETED") { + lines[resourceLine] = "${lines[resourceLine]} \u2190 $DRIFT_WARNING Resource deleted out-of-band" + return@forEach + } + + val lineIndex = findPropertyLine(lines, path, startFrom = resourceLine) + if (lineIndex >= 0) { + lines[lineIndex] = "${lines[lineIndex]} \u2190 $DRIFT_WARNING Drifted (Live AWS: ${drift.actualValue})" + } + } + } + return lines.joinToString("\n") +} + +private fun findPropertyLine(lines: List, path: String, startFrom: Int = 0): Int { + val parts = path.split("/").filter { it.isNotEmpty() } + var currentLine = startFrom + for (part in parts) { + if (part.all { it.isDigit() }) continue + val found = (currentLine + 1 until lines.size).firstOrNull { lines[it].contains("\"$part\"") } + if (found == null) return -1 + currentLine = found + } + return currentLine +} + +private fun ResourceChange.hasDrift(): Boolean = + resourceDriftStatus != null || + details?.any { it.target?.drift != null || it.target?.liveResourceDrift != null } == true + +internal fun ResourceChange.driftDisplay(): String { + if (resourceDriftStatus == "DELETED") return "$DRIFT_WARNING Deleted" + if (details?.any { it.target?.drift != null || it.target?.liveResourceDrift != null } == true) return "$DRIFT_WARNING Modified" + if (resourceDriftStatus != null && resourceDriftStatus != "IN_SYNC") return "$DRIFT_WARNING $resourceDriftStatus" + return "-" +} + +private class ResourceTableModel( + private val resources: List, + private val showDrift: Boolean, +) : AbstractTableModel() { + + private val columns = if (showDrift) { + arrayOf( + "Action", + "Logical ID", + "Physical ID", + "Type", + "Replacement", + "Drift Status", + ) + } else { + arrayOf( + "Action", + "Logical ID", + "Physical ID", + "Type", + "Replacement", + ) + } + + override fun getRowCount() = resources.size + override fun getColumnCount() = columns.size + override fun getColumnName(col: Int) = columns[col] + override fun getValueAt(row: Int, col: Int): Any { + val rc = resources[row] + return when (col) { + 0 -> rc.action.orEmpty() + 1 -> rc.logicalResourceId.orEmpty() + 2 -> rc.physicalResourceId.orEmpty() + 3 -> rc.resourceType.orEmpty() + 4 -> rc.replacement.orEmpty() + 5 -> if (showDrift) rc.driftDisplay() else "" + else -> "" + } + } +} + +private class DetailTableModel( + private val details: List, + private val showDrift: Boolean, +) : AbstractTableModel() { + + private val columns = if (showDrift) { + arrayOf( + "Attribute Change Type", + "Name", + "Requires Recreation", + "Before Value", + "After Value", + "Change Source", + "Causing Entity", + "Drift: Previous", + "Drift: Actual", + ) + } else { + arrayOf( + "Attribute Change Type", + "Name", + "Requires Recreation", + "Before Value", + "After Value", + "Change Source", + "Causing Entity", + ) + } + + override fun getRowCount() = details.size + override fun getColumnCount() = columns.size + override fun getColumnName(col: Int) = columns[col] + override fun getValueAt(row: Int, col: Int): Any { + val d = details[row] + val t = d.target + val drift = t?.drift ?: t?.liveResourceDrift + return when (col) { + 0 -> t?.attributeChangeType.orEmpty() + 1 -> t?.name ?: t?.attribute.orEmpty() + 2 -> t?.requiresRecreation.orEmpty() + 3 -> t?.beforeValue.orEmpty() + 4 -> t?.afterValue.orEmpty() + 5 -> d.changeSource.orEmpty() + 6 -> d.causingEntity.orEmpty() + 7 -> if (showDrift) drift?.previousValue ?: "-" else "" + 8 -> if (showDrift) drift?.actualValue ?: "-" else "" + else -> "" + } + } +} + +private class WarningCellRenderer : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: javax.swing.JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + val text = value?.toString().orEmpty() + if (text.isNotBlank() && text != "-") { + foreground = if (isSelected) table.selectionForeground else JBColor.YELLOW.darker() + } + return comp + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeDialogUtils.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeDialogUtils.kt new file mode 100644 index 00000000000..7aa6ce38103 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeDialogUtils.kt @@ -0,0 +1,49 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager + +object ResourceTypeDialogUtils { + private val LOG = getLogger() + + internal fun showResourceTypeSelectionDialog(project: Project, resourceTypesManager: ResourceTypesManager) { + val availableTypes = resourceTypesManager.getAvailableResourceTypes() + val selectedTypes = resourceTypesManager.getSelectedResourceTypes() + + if (availableTypes.isEmpty()) { + return + } + + try { + val dialog = ResourceTypeSelectionDialog(project, availableTypes, selectedTypes) + if (dialog.showAndGet()) { + // Handle both additions and removals + val newSelections = dialog.selectedResourceTypes.toSet() + + // Add new selections + newSelections.forEach { type -> + if (type !in selectedTypes) { + resourceTypesManager.addResourceType(type) + } + } + + // Remove deselected types + selectedTypes.forEach { type -> + if (type !in newSelections) { + resourceTypesManager.removeResourceType(type) + } + } + + LOG.info { "Finished updating resource types" } + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to show dialog" } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeSelectionDialog.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeSelectionDialog.kt new file mode 100644 index 00000000000..7845b47308e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeSelectionDialog.kt @@ -0,0 +1,171 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.psi.codeStyle.NameUtil +import com.intellij.ui.SearchTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.Component +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JTable +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer + +internal class ResourceTypeSelectionDialog( + project: Project, + private val availableTypes: List, + selectedTypes: Set = emptySet(), +) : DialogWrapper(project) { + var selectedResourceTypes: List = emptyList() + private set + + private val searchField = SearchTextField(false) + private val currentSelections = selectedTypes.toMutableSet() + private val tableModel = ResourceTypeTableModel(availableTypes, currentSelections) + private val table = createTable() + + init { + title = message("cloudformation.explorer.resources.dialog.title") + init() + setupSearch() + filterList() + } + + override fun getPreferredFocusedComponent() = searchField + + private fun createTable() = JBTable(tableModel).apply { + setShowGrid(false) + + tableHeader = null + rowHeight = TABLE_ROW_HEIGHT + + setDefaultRenderer(Boolean::class.java, ResourceTypeCellRenderer(tableModel)) + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val table = e.source as JTable + val row = table.rowAtPoint(e.point) + if (row >= 0) { + val currentValue = tableModel.getValueAt(row, 0) + tableModel.setValueAt(!currentValue, row, 0) + searchField.requestFocus() + } + } + }) + } + + private fun setupSearch() { + searchField.addDocumentListener(object : DocumentListener { + override fun insertUpdate(e: DocumentEvent?) = filterList() + override fun removeUpdate(e: DocumentEvent?) = filterList() + override fun changedUpdate(e: DocumentEvent?) = filterList() + }) + } + + private fun filterList() { + val searchText = searchField.text + val filteredTypes = if (searchText.isEmpty()) { + availableTypes + } else { + val matcher = NameUtil.buildMatcher("*$searchText*", NameUtil.MatchingCaseSensitivity.NONE) + availableTypes.filter { matcher.matches(it) } + } + + tableModel.updateFilter(filteredTypes) + } + + override fun createCenterPanel() = panel { + row { + cell(searchField).align(AlignX.FILL) + } + row { + scrollCell(table).align(Align.FILL) + }.resizableRow() + }.apply { + preferredSize = JBUI.size(DIALOG_WIDTH, DIALOG_HEIGHT) + } + + override fun doOKAction() { + selectedResourceTypes = currentSelections.toList() + super.doOKAction() + } + + companion object { + private const val TABLE_ROW_HEIGHT = 24 + private const val DIALOG_WIDTH = 400 + private const val DIALOG_HEIGHT = 300 + } +} + +private class ResourceTypeTableModel( + availableTypes: List, + private val selections: MutableSet, +) : AbstractTableModel() { + private var filteredTypes = availableTypes.toList() + + override fun getRowCount() = filteredTypes.size + override fun getColumnCount() = 1 + override fun getColumnClass(col: Int) = Boolean::class.java + override fun getValueAt(row: Int, col: Int) = filteredTypes[row] in selections + override fun isCellEditable(row: Int, col: Int) = true + + override fun setValueAt(value: Any, row: Int, col: Int) { + if (value is Boolean) { + val type = filteredTypes[row] + + if (value) selections.add(type) else selections.remove(type) + + fireTableCellUpdated(row, col) + } + } + + fun updateFilter(types: List) { + filteredTypes = types + fireTableDataChanged() + } + + fun getResourceType(row: Int) = filteredTypes[row] +} + +private class ResourceTypeCellRenderer( + private val tableModel: ResourceTypeTableModel, +) : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val label = JBLabel(tableModel.getResourceType(row)) + + label.icon = if (value as Boolean) AllIcons.Actions.Checked else EmptyIcon.ICON_16 + label.iconTextGap = ICON_TEXT_GAP + label.border = JBUI.Borders.emptyLeft(LEFT_BORDER) + label.background = if (isSelected) table.selectionBackground else table.background + label.foreground = if (isSelected) table.selectionForeground else table.foreground + label.isOpaque = true + + return label + } + + companion object { + private const val ICON_TEXT_GAP = 8 + private const val LEFT_BORDER = 8 + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt new file mode 100644 index 00000000000..61456e91848 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt @@ -0,0 +1,84 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.amazon.awssdk.arns.Arn +import java.awt.AlphaComposite +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import java.net.URLEncoder +import javax.swing.Icon +import javax.swing.ImageIcon + +internal object ConsoleUrlGenerator { + fun generateUrl(arn: String): String = + "https://console.aws.amazon.com/go/view?arn=${URLEncoder.encode(arn, "UTF-8")}" + + fun generateStackResourcesUrl(stackArn: String): String = + arnToConsoleTabUrl(stackArn, "resources") + + fun generateStackOutputsUrl(stackArn: String): String = + arnToConsoleTabUrl(stackArn, "outputs") + + fun generateStackEventsUrl(stackArn: String): String = + arnToConsoleTabUrl(stackArn, "events") + + fun generateOperationUrl(arn: String, operationId: String): String { + val region = try { + Arn.fromString(arn).region().orElse("us-east-1") + } catch (e: Exception) { + "us-east-1" + } + return "https://$region.console.aws.amazon.com/cloudformation/home?region=$region#/stacks/operations/info?stackId=${URLEncoder.encode( + arn, + "UTF-8" + )}&operationId=$operationId" + } + + private fun arnToConsoleTabUrl(arn: String, tab: String): String { + val region = try { + Arn.fromString(arn).region().orElse("us-east-1") + } catch (e: Exception) { + "us-east-1" + } + return "https://$region.console.aws.amazon.com/cloudformation/home?region=$region#/stacks/$tab?stackId=${URLEncoder.encode(arn, "UTF-8")}" + } +} + +internal object IconUtils { + private fun createBlueIcon(): Icon { + val size = 16 + val image = UIUtil.createImage(size, size, BufferedImage.TYPE_INT_ARGB) + val g2d = image.createGraphics() + + AllIcons.Ide.External_link_arrow.paintIcon(null, g2d, 0, 0) + + g2d.color = JBColor(0x0366D6, 0x58A6FF) + g2d.composite = AlphaComposite.SrcAtop + g2d.fillRect(0, 0, size, size) + + g2d.dispose() + return ImageIcon(image) + } + + fun createConsoleLinkIcon(urlProvider: () -> String?): JBLabel = + JBLabel(createBlueIcon()).apply { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + isVisible = false + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + urlProvider()?.let { url -> + BrowserUtil.browse(url) + } + } + }) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployDialog.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployDialog.kt new file mode 100644 index 00000000000..59564ad8d2b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployDialog.kt @@ -0,0 +1,623 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.ide.wizard.AbstractWizard +import com.intellij.ide.wizard.StepAdapter +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.CheckBoxList +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.CfnDocumentManager +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeploymentMode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Parameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceToImport +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Tag +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateParameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateResource +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CFN_SUPPORTED_EXTENSIONS +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.BorderLayout +import java.io.File +import java.net.URI +import javax.swing.DefaultComboBoxModel +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.table.AbstractTableModel + +internal data class TemplateItem( + val displayName: String, + val uri: String?, +) { + override fun toString() = displayName +} + +internal data class ValidateAndDeploySettings( + val templatePath: String, + val stackName: String, + val s3Bucket: String?, + val s3Key: String?, + val parameters: List, + val capabilities: List, + val tags: List, + val onStackFailure: String?, + val includeNestedStacks: Boolean, + val importExistingResources: Boolean, + val deploymentMode: DeploymentMode?, + val resourcesToImport: List?, +) + +internal class ValidateAndDeployWizard( + project: Project, + documentManager: CfnDocumentManager, + prefilledTemplatePath: String? = null, + prefilledStackName: String? = null, + templateParameters: List = emptyList(), + detectedCapabilities: List = emptyList(), + existingParameters: List? = null, + existingTags: List? = null, + hasArtifacts: Boolean = false, + templateResources: List = emptyList(), + isExistingStack: Boolean = false, +) : AbstractWizard(message("cloudformation.deploy.dialog.title"), project) { + + private val configStep = ConfigurationStep( + project, documentManager, prefilledTemplatePath, prefilledStackName, + templateParameters, detectedCapabilities, existingParameters, existingTags, + hasArtifacts, templateResources, isExistingStack, + ) + private val importStep = ImportResourcesStep(templateResources) + + init { + addStep(configStep) + if (templateResources.isNotEmpty()) { + addStep(importStep) + } + configStep.setImportToggleListener { updateWizardButtons() } + init() + } + + override fun getHelpId(): String? = null + + override fun helpAction() { + BrowserUtil.browse(HELP_URL) + } + + override fun doHelpAction() { + BrowserUtil.browse(HELP_URL) + } + + override fun updateButtons() { + super.updateButtons() + if (isLastStep() || !configStep.isImportSelected()) { + nextButton.text = "Create Change Set" + } + } + + override fun canGoNext(): Boolean { + if (currentStep == 0 && !configStep.isImportSelected()) return true + return super.canGoNext() + } + + override fun doNextAction() { + if (currentStep == 0) { + val error = configStep.validate() + if (error != null) { + setErrorText(error) + return + } + setErrorText(null) + } + super.doNextAction() + } + + override fun doOKAction() { + if (currentStep == 0) { + val error = configStep.validate() + if (error != null) { + setErrorText(error) + return + } + } + if (currentStep == 1 || (currentStep == 0 && configStep.isImportSelected())) { + val error = importStep.validate() + if (error != null) { + setErrorText(error) + return + } + } + super.doOKAction() + } + + override fun isLastStep(): Boolean { + if (currentStep == 0 && !configStep.isImportSelected()) return true + return currentStep == stepCount - 1 + } + + fun getSettings(): ValidateAndDeploySettings { + configStep.saveState() + return ValidateAndDeploySettings( + templatePath = configStep.getTemplatePath(), + stackName = configStep.getStackName(), + s3Bucket = configStep.getS3Bucket(), + s3Key = configStep.getS3Key(), + parameters = configStep.getParameters(), + capabilities = configStep.getCapabilities(), + tags = configStep.getTags(), + onStackFailure = configStep.getOnStackFailure(), + includeNestedStacks = configStep.getIncludeNestedStacks(), + importExistingResources = configStep.isImportSelected(), + deploymentMode = configStep.getDeploymentMode(), + resourcesToImport = if (configStep.isImportSelected()) importStep.getResourcesToImport() else null, + ) + } + + companion object { + private const val HELP_URL = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-changesets-create.html" + } +} + +// Step 1: Main configuration +private class ConfigurationStep( + project: Project, + private val documentManager: CfnDocumentManager, + prefilledTemplatePath: String?, + prefilledStackName: String?, + templateParameters: List, + detectedCapabilities: List, + existingParameters: List?, + existingTags: List?, + private val hasArtifacts: Boolean, + templateResources: List, + private val isExistingStack: Boolean, +) : StepAdapter() { + + private val persistence = ValidateAndDeployPersistence.getInstance(project) + private val savedState = persistence.state + + private val descriptor = FileChooserDescriptor(true, false, false, false, false, false).withFileFilter { + it.extension?.lowercase() in CFN_SUPPORTED_EXTENSIONS + } + + private val templateDropdown = ComboBox().apply { + renderer = SimpleListCellRenderer.create { label, item, _ -> + label.text = item.displayName + label.toolTipText = item.uri?.let { URI(it).path } + } + addActionListener { toolTipText = getSelectedTooltip() } + } + + private val browseButton = JButton().apply { + icon = AllIcons.General.OpenDisk + addActionListener { + val selectedFile = FileChooser.chooseFile(descriptor, project, null) + selectedFile?.let { file -> addFileToDropdown(file.path) } + } + toolTipText = "Browse for CloudFormation template" + margin = JBUI.emptyInsets() + val iconSize = icon.iconWidth + 24 + preferredSize = JBUI.size(iconSize, preferredSize.height) + minimumSize = JBUI.size(iconSize, minimumSize.height) + maximumSize = JBUI.size(iconSize, maximumSize.height) + } + + private val stackNameField = JBTextField().apply { + text = prefilledStackName ?: savedState.lastStackName.orEmpty() + emptyText.text = message("cloudformation.deploy.dialog.stack_name.placeholder") + } + + private val s3BucketField = JBTextField().apply { + text = savedState.s3Bucket.orEmpty() + emptyText.text = "S3 bucket name (optional)" + } + + private val s3KeyField = JBTextField().apply { + val defaultKey = if (prefilledTemplatePath != null) { + val f = File(prefilledTemplatePath) + "${f.nameWithoutExtension}-${System.currentTimeMillis()}.${f.extension}" + } else { + null + } + text = savedState.s3Key.orEmpty() + emptyText.text = defaultKey ?: "S3 object key (optional)" + } + + private val parameterFields = templateParameters.map { param -> + val prefill = existingParameters?.find { it.parameterKey == param.name }?.parameterValue + ?: param.default?.toString().orEmpty() + param to JBTextField().apply { + text = prefill + emptyText.text = param.description ?: param.type ?: "String" + } + } + + private val capabilityIam = JBCheckBox("CAPABILITY_IAM").apply { + isSelected = "CAPABILITY_IAM" in detectedCapabilities || savedState.capabilities?.contains("CAPABILITY_IAM") == true + } + private val capabilityNamedIam = JBCheckBox("CAPABILITY_NAMED_IAM").apply { + isSelected = "CAPABILITY_NAMED_IAM" in detectedCapabilities || savedState.capabilities?.contains("CAPABILITY_NAMED_IAM") == true + } + private val capabilityAutoExpand = JBCheckBox("CAPABILITY_AUTO_EXPAND").apply { + isSelected = "CAPABILITY_AUTO_EXPAND" in detectedCapabilities || savedState.capabilities?.contains("CAPABILITY_AUTO_EXPAND") == true + } + + private val tagsField = JBTextField().apply { + val existingTagStr = existingTags?.joinToString(",") { "${it.key}=${it.value}" } + text = existingTagStr ?: savedState.tags.orEmpty() + emptyText.text = "key1=value1,key2=value2 (optional)" + } + + private val onStackFailureCombo = ComboBox(DefaultComboBoxModel(arrayOf("DO_NOTHING", "ROLLBACK", "DELETE"))).apply { + selectedItem = savedState.onStackFailure ?: "DO_NOTHING" + } + + private val includeNestedStacksCheckbox = JBCheckBox("Include nested stacks").apply { + isSelected = savedState.includeNestedStacks + } + + private val deploymentModeCombo = ComboBox(DefaultComboBoxModel(arrayOf("Standard", "Revert Drift"))).apply { + selectedItem = "Standard" + isEnabled = isExistingStack + } + + private val importResourcesCheckbox = JBCheckBox("Import existing resources") + + private var onImportToggled: (() -> Unit)? = null + + init { + populateTemplateDropdown() + prefilledTemplatePath?.let { addFileToDropdown(it) } + templateDropdown.toolTipText = getSelectedTooltip() + } + + private fun getSelectedTooltip(): String? { + val selectedItem = templateDropdown.selectedItem as? TemplateItem + return selectedItem?.uri?.let { URI(it).path } + } + + private fun populateTemplateDropdown() { + val templates = documentManager.getValidTemplates() + templateDropdown.removeAllItems() + + if (templates.isEmpty()) { + templateDropdown.addItem(TemplateItem("Click browse button to select template", null)) + } else { + templates.sortedBy { it.fileName }.forEach { template -> + templateDropdown.addItem(TemplateItem(template.fileName, template.uri)) + } + } + } + + private fun addFileToDropdown(filePath: String) { + val file = File(filePath) + if (!file.exists() || !file.isFile) return + + val fileUri = "file://$filePath" + + val existingItem = (0 until templateDropdown.itemCount) + .map { templateDropdown.getItemAt(it) } + .find { it.uri == fileUri } + + if (existingItem != null) { + templateDropdown.selectedItem = existingItem + } else { + val newItem = TemplateItem(file.name, fileUri) + templateDropdown.addItem(newItem) + templateDropdown.selectedItem = newItem + } + templateDropdown.toolTipText = getSelectedTooltip() + } + + private val component = panel { + group("Template & Stack") { + row(message("cloudformation.deploy.dialog.template.label")) { + cell(templateDropdown).align(Align.FILL).resizableColumn() + cell(browseButton).align(AlignX.LEFT) + } + row(message("cloudformation.deploy.dialog.stack_name.label")) { + cell(stackNameField).align(Align.FILL) + } + } + if (hasArtifacts || s3BucketField.text.isNotBlank()) { + group("S3 Upload") { + row("Bucket:") { cell(s3BucketField).align(Align.FILL) } + row("Key:") { cell(s3KeyField).align(Align.FILL) } + } + } else { + collapsibleGroup("S3 Upload") { + row("Bucket:") { cell(s3BucketField).align(Align.FILL) } + row("Key:") { cell(s3KeyField).align(Align.FILL) } + } + } + if (parameterFields.isNotEmpty()) { + group("Parameters") { + parameterFields.forEach { (param, field) -> + val label = if (param.allowedValues != null) { + "${param.name} (${param.allowedValues.joinToString(", ")}):" + } else { + "${param.name}:" + } + row(label) { cell(field).align(Align.FILL) } + } + } + } + group("Capabilities") { + row { cell(capabilityIam) } + row { cell(capabilityNamedIam) } + row { cell(capabilityAutoExpand) } + } + if (templateResources.isNotEmpty()) { + group("Resource Import") { + row { cell(importResourcesCheckbox) } + } + } + collapsibleGroup("Advanced Options") { + row("Tags:") { cell(tagsField).align(Align.FILL) } + row("On stack failure:") { cell(onStackFailureCombo) } + if (isExistingStack) { + row("Deployment mode:") { cell(deploymentModeCombo) } + } + row { cell(includeNestedStacksCheckbox) } + } + }.apply { + preferredSize = JBUI.size(550, 550) + } + + override fun getComponent(): JComponent = component + + fun setImportToggleListener(listener: () -> Unit) { + onImportToggled = listener + importResourcesCheckbox.addActionListener { listener() } + } + + fun isImportSelected(): Boolean = importResourcesCheckbox.isSelected + + fun validate(): String? { + val selectedItem = templateDropdown.selectedItem as? TemplateItem + val uri = selectedItem?.uri + ?: return message("cloudformation.deploy.dialog.template.required") + + val file = File(URI(uri)) + if (!file.isFile) return message("cloudformation.deploy.dialog.template.not_found") + if (file.extension.lowercase() !in CFN_SUPPORTED_EXTENSIONS) return message("cloudformation.deploy.dialog.template.invalid_extension") + + val name = stackNameField.text.trim() + if (name.isBlank()) return message("cloudformation.deploy.dialog.stack_name.required") + if (name.length > 128) return message("cloudformation.deploy.dialog.stack_name.too_long") + if (!STACK_NAME_PATTERN.matches(name)) return message("cloudformation.deploy.dialog.stack_name.invalid") + + if (hasArtifacts && s3BucketField.text.isBlank()) return "S3 bucket is required because template contains artifacts" + + val tags = tagsField.text.trim() + if (tags.isNotBlank() && !TAGS_PATTERN.matches(tags)) return "Tags format: key1=value1,key2=value2" + + for ((param, field) in parameterFields) { + validateParameter(field.text.trim(), param)?.let { return it } + } + return null + } + + private fun validateParameter(value: String, param: TemplateParameter): String? { + val actual = value.ifBlank { param.default?.toString().orEmpty() } + if (actual.isBlank()) return "${param.name}: Value is required" + if (param.allowedValues != null && actual !in param.allowedValues.map { it.toString() }) { + return "${param.name}: Must be one of: ${param.allowedValues.joinToString(", ")}" + } + if (param.allowedPattern != null && !Regex(param.allowedPattern).matches(actual)) { + return "${param.name}: Must match pattern: ${param.allowedPattern}" + } + if (param.minLength != null && actual.length < param.minLength) return "${param.name}: Min length: ${param.minLength}" + if (param.maxLength != null && actual.length > param.maxLength) return "${param.name}: Max length: ${param.maxLength}" + if (param.type == "Number") { + val num = actual.toDoubleOrNull() ?: return "${param.name}: Must be a number" + if (param.minValue != null && num < param.minValue.toDouble()) return "${param.name}: Min value: ${param.minValue}" + if (param.maxValue != null && num > param.maxValue.toDouble()) return "${param.name}: Max value: ${param.maxValue}" + } + return null + } + + fun saveState() { + val state = persistence.state + val selectedItem = templateDropdown.selectedItem as? TemplateItem + state.lastTemplatePath = selectedItem?.uri?.let { URI(it).path } + state.lastStackName = stackNameField.text.trim() + state.s3Bucket = s3BucketField.text.trim().ifBlank { null } + state.s3Key = s3KeyField.text.trim().ifBlank { null } + state.onStackFailure = onStackFailureCombo.selectedItem as? String + state.includeNestedStacks = includeNestedStacksCheckbox.isSelected + state.importExistingResources = importResourcesCheckbox.isSelected + state.tags = tagsField.text.trim().ifBlank { null } + val caps = mutableListOf() + if (capabilityIam.isSelected) caps.add("CAPABILITY_IAM") + if (capabilityNamedIam.isSelected) caps.add("CAPABILITY_NAMED_IAM") + if (capabilityAutoExpand.isSelected) caps.add("CAPABILITY_AUTO_EXPAND") + state.capabilities = caps.joinToString(",").ifBlank { null } + } + + fun getTemplatePath(): String { + val selectedItem = templateDropdown.selectedItem as? TemplateItem + return selectedItem?.uri?.let { URI(it).path }.orEmpty() + } + + fun getStackName(): String = stackNameField.text.trim() + fun getS3Bucket(): String? = s3BucketField.text.trim().ifBlank { null } + fun getS3Key(): String? = s3KeyField.text.trim().ifBlank { + if (s3BucketField.text.isNotBlank()) { + val path = getTemplatePath() + if (path.isNotBlank()) { + val f = File(path) + "${f.nameWithoutExtension}-${System.currentTimeMillis()}.${f.extension}" + } else { + null + } + } else { + null + } + } + fun getParameters(): List = parameterFields.map { (param, field) -> + Parameter(param.name, field.text.trim().ifBlank { param.default?.toString().orEmpty() }) + } + fun getCapabilities(): List = mutableListOf().apply { + if (capabilityIam.isSelected) add("CAPABILITY_IAM") + if (capabilityNamedIam.isSelected) add("CAPABILITY_NAMED_IAM") + if (capabilityAutoExpand.isSelected) add("CAPABILITY_AUTO_EXPAND") + } + fun getTags(): List = parseTags(tagsField.text.trim()) + fun getOnStackFailure(): String? = onStackFailureCombo.selectedItem as? String + fun getIncludeNestedStacks(): Boolean = includeNestedStacksCheckbox.isSelected + fun getDeploymentMode(): DeploymentMode? = + if (isExistingStack && deploymentModeCombo.selectedItem == "Revert Drift") DeploymentMode.REVERT_DRIFT else null + + private fun parseTags(input: String): List { + if (input.isBlank()) return emptyList() + return input.split(",").mapNotNull { pair -> + val parts = pair.split("=", limit = 2) + if (parts.size == 2) Tag(parts[0].trim(), parts[1].trim()) else null + } + } + + companion object { + private val STACK_NAME_PATTERN = Regex("^[a-zA-Z][-a-zA-Z0-9]*$") + private val TAGS_PATTERN = Regex("^[^=,]+=[^=,]+(,[^=,]+=[^=,]+)*$") + } +} + +// Step 2: Import resources +private class ImportResourcesStep( + private val templateResources: List, +) : StepAdapter() { + + private val resourceCheckboxList = CheckBoxList().apply { + templateResources.forEach { addItem(it, "${it.logicalId} (${it.type})", false) } + } + + private val identifierTableModel = IdentifierTableModel() + private val identifierTable = JBTable(identifierTableModel).apply { + setShowGrid(true) + } + + private val component = JBPanel>(BorderLayout()).apply { + val topPanel = panel { + group("Select Resources to Import") { + row { + cell( + JBScrollPane(resourceCheckboxList).apply { + preferredSize = JBUI.size(530, 150) + } + ).align(Align.FILL) + } + } + } + + val bottomPanel = panel { + group("Resource Identifiers") { + row { + cell( + JBScrollPane(identifierTable).apply { + preferredSize = JBUI.size(530, 200) + } + ).align(Align.FILL) + } + row { + comment("Enter the physical identifier for each selected resource's primary key") + } + } + } + + add(topPanel, BorderLayout.NORTH) + add(bottomPanel, BorderLayout.CENTER) + preferredSize = JBUI.size(550, 450) + + resourceCheckboxList.setCheckBoxListListener { _, _ -> refreshIdentifierTable() } + } + + private fun refreshIdentifierTable() { + val selected = templateResources.filter { resourceCheckboxList.isItemSelected(it) } + identifierTableModel.updateResources(selected) + } + + override fun getComponent(): JComponent = component + + fun getResourcesToImport(): List? { + val imports = identifierTableModel.getResourcesToImport() + return imports.ifEmpty { null } + } + + fun validate(): String? { + val blankResources = identifierTableModel.getResourcesWithBlankIdentifiers() + if (blankResources.isNotEmpty()) { + return "Missing identifiers for: ${blankResources.joinToString(", ")}" + } + return null + } +} + +private class IdentifierTableModel : AbstractTableModel() { + private val columns = arrayOf("Logical ID", "Type", "Identifier Key", "Identifier Value") + private val rows = mutableListOf() + + data class IdentifierRow( + val logicalId: String, + val type: String, + val key: String, + var value: String, + val prefilled: Boolean, + ) + + fun updateResources(resources: List) { + rows.clear() + for (resource in resources) { + val keys = resource.primaryIdentifierKeys ?: continue + for (key in keys) { + val prefilledValue = resource.primaryIdentifier?.get(key) + rows.add(IdentifierRow(resource.logicalId, resource.type, key, prefilledValue.orEmpty(), prefilledValue != null)) + } + } + fireTableDataChanged() + } + + fun getResourcesToImport(): List { + val grouped = rows.groupBy { it.logicalId } + return grouped.mapNotNull { (logicalId, identifierRows) -> + val identifiers = identifierRows.associate { it.key to it.value } + if (identifiers.values.any { it.isBlank() }) return@mapNotNull null + val type = identifierRows.first().type + ResourceToImport(resourceType = type, logicalResourceId = logicalId, resourceIdentifier = identifiers) + } + } + + fun getResourcesWithBlankIdentifiers(): List = + rows.filter { it.value.isBlank() }.map { "${it.logicalId}.${it.key}" }.distinct() + + override fun getRowCount() = rows.size + override fun getColumnCount() = columns.size + override fun getColumnName(col: Int) = columns[col] + override fun isCellEditable(row: Int, col: Int) = col == 3 + override fun getValueAt(row: Int, col: Int): Any = when (col) { + 0 -> rows[row].logicalId + 1 -> rows[row].type + 2 -> rows[row].key + 3 -> rows[row].value + else -> "" + } + override fun setValueAt(value: Any?, row: Int, col: Int) { + if (col == 3) { + rows[row].value = value?.toString().orEmpty() + fireTableCellUpdated(row, col) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployPersistence.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployPersistence.kt new file mode 100644 index 00000000000..df804e92e46 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployPersistence.kt @@ -0,0 +1,37 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project + +@Service(Service.Level.PROJECT) +@State(name = "cfnValidateAndDeploySettings", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class ValidateAndDeployPersistence : PersistentStateComponent { + private var state = ValidateAndDeployPersistenceState() + + override fun getState(): ValidateAndDeployPersistenceState = state + override fun loadState(state: ValidateAndDeployPersistenceState) { this.state = state } + + companion object { + fun getInstance(project: Project): ValidateAndDeployPersistence = project.service() + } +} + +internal data class ValidateAndDeployPersistenceState( + var lastTemplatePath: String? = null, + var lastStackName: String? = null, + var s3Bucket: String? = null, + var s3Key: String? = null, + var onStackFailure: String? = null, + var includeNestedStacks: Boolean = false, + var importExistingResources: Boolean = false, + var tags: String? = null, + var capabilities: String? = null, +) diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt new file mode 100644 index 00000000000..c0c840d5b2d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt @@ -0,0 +1,21 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.ui.components.JBTextArea +import java.awt.Dimension +import java.awt.Font + +internal class WrappingTextArea(text: String) : JBTextArea(text) { + init { + isEditable = false + isOpaque = false + lineWrap = true + wrapStyleWord = true + font = font.deriveFont(Font.PLAIN) + border = null + } + + override fun getMinimumSize(): Dimension = Dimension(50, 20) +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt index bf23a37489e..6fdc40beadc 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt @@ -10,8 +10,6 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.Separator -import com.intellij.openapi.actionSystem.ToggleAction -import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import software.aws.toolkits.core.credentials.CredentialIdentifier @@ -72,7 +70,7 @@ class ConnectionSettingsMenuBuilder private constructor() { val actions = when (settings) { is SelectableIdentitySelectionSettings -> { connections.map { - object : DumbAwareToggleAction( + object : DumbAwareSelectAction( title = it.label, value = it, selected = it == settings.currentSelection, @@ -196,18 +194,20 @@ class ConnectionSettingsMenuBuilder private constructor() { // Helper actions, note: these are public to help make tests easier by leveraging instanceOf checks - abstract inner class DumbAwareToggleAction( + abstract inner class DumbAwareSelectAction( title: String, val value: T, private val selected: Boolean, private val onSelect: (T) -> Unit, - ) : ToggleAction(title), DumbAware { + ) : DumbAwareAction(title) { override fun getActionUpdateThread() = ActionUpdateThread.BGT - override fun isSelected(e: AnActionEvent): Boolean = selected + override fun update(e: AnActionEvent) { + e.presentation.icon = if (selected) AllIcons.Actions.Checked else null + } - override fun setSelected(e: AnActionEvent, state: Boolean) { - if (!isSelected(e)) { + override fun actionPerformed(e: AnActionEvent) { + if (!selected) { onSelect.invoke(value) } } @@ -217,13 +217,13 @@ class ConnectionSettingsMenuBuilder private constructor() { value: AwsRegion, selected: Boolean, onSelect: (AwsRegion) -> Unit, - ) : DumbAwareToggleAction(value.displayName, value, selected, onSelect) + ) : DumbAwareSelectAction(value.displayName, value, selected, onSelect) inner class SwitchCredentialsAction( value: CredentialIdentifier, selected: Boolean, onSelect: (CredentialIdentifier) -> Unit, - ) : DumbAwareToggleAction(value.displayName, value, selected, onSelect) + ) : DumbAwareSelectAction(value.displayName, value, selected, onSelect) inner class IndividualIdentityActionGroup(private val value: AwsBearerTokenConnection) : DefaultActionGroup( diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt index f1eb5506f5e..4b5701905b4 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt @@ -50,9 +50,10 @@ import javax.swing.tree.TreePath abstract class AbstractExplorerTreeToolWindow( treeStructure: AbstractTreeStructure, + initialTreeExpandDepth: Int = 2, ) : SimpleToolWindowPanel(true, true), DataProvider, Disposable { private val treeModel = StructureTreeModel(treeStructure, null, Invoker.forBackgroundPoolWithoutReadAction(this), this) - private val tree = Tree(AsyncTreeModel(treeModel, true, this)) + protected val tree = Tree(AsyncTreeModel(treeModel, true, this)) init { background = UIUtil.getTreeBackground() @@ -142,7 +143,7 @@ abstract class AbstractExplorerTreeToolWindow( redrawContent() - TreeUtil.expand(tree, 2) + TreeUtil.expand(tree, initialTreeExpandDepth) } abstract val actionPlace: String diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt index fc11a1afa5b..a6246182759 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt @@ -55,6 +55,11 @@ class AwsToolkitExplorerToolWindow( if (!isQInstalled()) { put(Q_TAB_ID, { CodewhispererQToolWindow.getInstance(project) }) } + ToolkitToolWindowTab.EP_NAME.extensionList + .filter { it.enabled() } + .forEach { tab -> + put(tab.tabId, { tab.createContent(project) }) + } } init { diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowTab.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowTab.kt new file mode 100644 index 00000000000..dfe0ae89602 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowTab.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import java.awt.Component + +interface ToolkitToolWindowTab { + val tabId: String + fun createContent(project: Project): Component + fun enabled(): Boolean = true + + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.toolWindowTab") + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt new file mode 100644 index 00000000000..7d06dfa15b4 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt @@ -0,0 +1,28 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.system.CpuArch +import java.nio.file.Path +import java.nio.file.Paths + +internal fun getToolkitsCacheRoot(): Path = when { + SystemInfo.isWindows -> Paths.get(System.getenv("LOCALAPPDATA")) + SystemInfo.isMac -> Paths.get(System.getProperty("user.home"), "Library", "Caches") + else -> Paths.get(System.getProperty("user.home"), ".cache") +}.resolve("aws").resolve("toolkits") + +internal fun getCurrentOS(): String = when { + SystemInfo.isWindows -> "windows" + SystemInfo.isMac -> "darwin" + else -> "linux" +} + +internal fun getCurrentArchitecture(): String = when (CpuArch.CURRENT) { + CpuArch.ARM32, + CpuArch.ARM64, + -> "arm64" + else -> "x64" +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt new file mode 100644 index 00000000000..01bf2965ce7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt @@ -0,0 +1,162 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.util.SystemInfo +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path + +internal enum class Platform { MAC, LINUX, WINDOWS } + +private val BIN_DIR = mapOf(Platform.MAC to "bin/", Platform.LINUX to "bin/", Platform.WINDOWS to "") +private val EXE_NAME = mapOf(Platform.MAC to "node", Platform.LINUX to "node", Platform.WINDOWS to "node.exe") + +@VisibleForTesting +internal fun buildWellKnownPaths(platform: Platform, home: Path): List { + val exeName = EXE_NAME.getValue(platform) + return buildList { + if (platform == Platform.MAC) { + add(Path.of("/opt/homebrew/bin/$exeName")) + add(Path.of("/usr/local/bin/$exeName")) + add(home.resolve(".asdf/shims/$exeName")) + } + if (platform == Platform.LINUX) { + add(Path.of("/usr/bin/$exeName")) + add(Path.of("/usr/local/bin/$exeName")) + add(Path.of("/snap/bin/$exeName")) + add(Path.of("/home/linuxbrew/.linuxbrew/bin/$exeName")) + add(home.resolve(".asdf/shims/$exeName")) + } + if (platform == Platform.WINDOWS) { + add(Path.of("C:/Program Files/nodejs/$exeName")) + add(Path.of("C:/ProgramData/chocolatey/bin/$exeName")) + add(home.resolve("scoop/apps/nodejs/current/$exeName")) + } + } +} + +@VisibleForTesting +internal fun buildGlobPatterns(platform: Platform, home: Path, env: (String) -> String?): List { + val exeName = EXE_NAME.getValue(platform) + val bin = BIN_DIR.getValue(platform) + + return buildList { + if (platform == Platform.MAC) { + add("/opt/homebrew/Cellar/node*/*/bin/$exeName") + add("/usr/local/Cellar/node*/*/bin/$exeName") + } + + // nvm + val nvmDir = env("NVM_DIR")?.let { Path.of(it) } ?: home.resolve(".nvm") + if (platform != Platform.WINDOWS) { + add("$nvmDir/versions/node/v*/bin/$exeName") + } else { + val nvmHome = env("NVM_HOME")?.let { Path.of(it) } + ?: env("APPDATA")?.let { Path.of(it, "nvm") } + nvmHome?.let { add("$it/v*/$exeName") } + } + + // fnm + val fnmBase = when (platform) { + Platform.MAC -> home.resolve("Library/Application Support/fnm") + Platform.LINUX -> (env("XDG_DATA_HOME")?.let { Path.of(it) } ?: home.resolve(".local/share")).resolve("fnm") + Platform.WINDOWS -> env("APPDATA")?.let { Path.of(it, "fnm") } + } + fnmBase?.let { add("$it/node-versions/v*/installation/${bin}$exeName") } + + // volta + val voltaHome = if (platform == Platform.WINDOWS) { + env("LOCALAPPDATA")?.let { Path.of(it, "Volta") } + } else { + home.resolve(".volta") + } + voltaHome?.let { add("$it/tools/image/node/*/${bin}$exeName") } + } +} + +/** + * Resolves a Node.js executable across system PATH, well-known install locations, + * and version managers (nvm, fnm, volta). GUI-launched IDEs don't inherit shell + * PATH modifications, so we search common locations directly. + */ +internal object NodeRuntimeResolver { + private val LOG = getLogger() + private val home: Path = Path.of(System.getProperty("user.home")) + + private val platform: Platform = when { + SystemInfo.isMac -> Platform.MAC + SystemInfo.isWindows -> Platform.WINDOWS + else -> Platform.LINUX + } + + private val exeName = EXE_NAME.getValue(platform) + private val wellKnownPaths: List = buildWellKnownPaths(platform, home) + private val globPatterns: List by lazy { buildGlobPatterns(platform, home) { System.getenv(it) } } + + fun resolve(minVersion: Int = 18): Path? = + resolveFromPath(minVersion) ?: resolveFromWellKnownLocations(minVersion) + + private fun resolveFromPath(minVersion: Int): Path? = + PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) + .asSequence() + .map { it.toPath() } + .filter { Files.isRegularFile(it) && Files.isExecutable(it) } + .firstNotNullOfOrNull { it.takeIfVersionAtLeast(minVersion) } + + private fun resolveFromWellKnownLocations(minVersion: Int): Path? { + val fromFixed = wellKnownPaths.asSequence() + .filter { Files.isRegularFile(it) && Files.isExecutable(it) } + + val fromGlobs = globPatterns.asSequence() + .flatMap { expandGlob(it) } + + return (fromFixed + fromGlobs) + .mapNotNull { path -> + val version = path.nodeVersion() + if (version != null && version >= minVersion) version to path.toAbsolutePath() else null + } + .maxByOrNull { it.first } + ?.second + } + + private fun expandGlob(glob: String): Sequence { + val parent = Path.of(glob.substringBefore("*")).parent ?: return emptySequence() + if (!Files.isDirectory(parent)) return emptySequence() + + val matcher = FileSystems.getDefault().getPathMatcher("glob:$glob") + val depth = glob.removePrefix(parent.toString()).count { it == '/' || it == '\\' } + 1 + + return Files.walk(parent, depth).use { stream -> + stream + .filter { matcher.matches(it) && Files.isRegularFile(it) && Files.isExecutable(it) } + .toList() + }.asSequence() + } + + private fun Path.nodeVersion(): Int? = try { + val output = ExecUtil.execAndGetOutput(GeneralCommandLine(toString(), "--version"), 5000) + if (output.exitCode == 0) output.stdout.trim().removePrefix("v").split(".")[0].toIntOrNull() else null + } catch (e: Exception) { + LOG.debug(e) { "Failed to get version from node at: $this" } + null + } + + private fun Path.takeIfVersionAtLeast(minVersion: Int): Path? { + val version = nodeVersion() ?: return null + return if (version >= minVersion) { + LOG.debug { "Node v$version found at: $this" } + toAbsolutePath() + } else { + LOG.debug { "Node v$version < $minVersion at: $this" } + null + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt index 19559ace910..1c5e1da86c8 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt @@ -55,8 +55,8 @@ class MinimumVersionChange @JvmOverloads constructor(isUnderTest: Boolean = fals } companion object { - const val MIN_VERSION = 243 - const val MIN_VERSION_HUMAN = "2024.3" + const val MIN_VERSION = 251 + const val MIN_VERSION_HUMAN = "2025.1" // Used by tests to make sure the prompt never shows up const val SKIP_PROMPT = "aws.suppress_deprecation_prompt" diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspExtensionConfig.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspExtensionConfig.kt new file mode 100644 index 00000000000..b7c88539efd --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspExtensionConfig.kt @@ -0,0 +1,15 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import software.aws.toolkits.jetbrains.AwsPlugin +import software.aws.toolkits.jetbrains.AwsToolkit + +object CfnLspExtensionConfig { + const val EXTENSION_NAME: String = AwsToolkit.TOOLKIT_PLUGIN_ID + val EXTENSION_VERSION: String = AwsToolkit.PLUGINS_INFO[AwsPlugin.TOOLKIT]?.version ?: "unknown" + const val ENCRYPTION_MODE = "JWT" + const val TELEMETRY_NOTIFICATION_GROUP_ID = "aws.cfn.telemetry" + const val INTRO_NOTIFICATION_GROUP_ID = "CloudFormation LSP Introduction" +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt new file mode 100644 index 00000000000..fb56dbe4cd9 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt @@ -0,0 +1,132 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import org.eclipse.lsp4j.services.LanguageServer +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ClearStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateDeploymentParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeleteChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeletionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeploymentStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetCapabilitiesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetParametersResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateArtifactsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStackResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTypesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsResult +import java.util.concurrent.CompletableFuture + +internal interface CfnLspServerProtocol : LanguageServer { + @JsonRequest("aws/credentials/iam/update") + fun updateIamCredentials(params: UpdateCredentialsParams): CompletableFuture + + @JsonRequest("aws/cfn/stacks") + fun listStacks(params: ListStacksParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/list") + fun listChangeSets(params: ListChangeSetsParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/types") + fun listResourceTypes(): CompletableFuture + + @JsonRequest("aws/cfn/resources/list") + fun listResources(params: ListResourcesParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/state") + fun getResourceState(params: ResourceStateParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/stackMgmtInfo") + fun getStackManagementInfo(resourceIdentifier: String): CompletableFuture + + @JsonRequest("aws/cfn/resources/search") + fun searchResource(params: SearchResourceParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/refresh") + fun refreshResources(params: RefreshResourcesParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/list/remove") + fun removeResourceType(resourceType: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/validation/create") + fun createValidation(params: CreateValidationParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/validation/status") + fun getValidationStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/validation/status/describe") + fun describeValidationStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/describe") + fun describeChangeSet(params: DescribeChangeSetParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/delete") + fun deleteChangeSet(params: DeleteChangeSetParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/deletion/status") + fun getChangeSetDeletionStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/deletion/status/describe") + fun describeChangeSetDeletionStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/deployment/create") + fun createDeployment(params: CreateDeploymentParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/deployment/status") + fun getDeploymentStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/deployment/status/describe") + fun describeDeploymentStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/parameters") + fun getParameters(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/capabilities") + fun getCapabilities(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/import/resources") + fun getTemplateResources(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/template/artifacts") + fun getTemplateArtifacts(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/describe") + fun describeStack(params: DescribeStackParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/resources") + fun getStackResources(params: GetStackResourcesParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/events") + fun getStackEvents(params: GetStackEventsParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/events/clear") + fun clearStackEvents(params: ClearStackEventsParams): CompletableFuture +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnNodePromptState.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnNodePromptState.kt new file mode 100644 index 00000000000..bccb8da9078 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnNodePromptState.kt @@ -0,0 +1,39 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +private const val FIFTEEN_DAYS_MS = 15L * 24 * 60 * 60 * 1000 + +@Service +@State(name = "cfnNodePromptState", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnNodePromptState : PersistentStateComponent { + private var state = State() + + override fun getState(): State = state + override fun loadState(state: State) { this.state = state } + + fun shouldPrompt(): Boolean { + if (state.lastPromptTime == 0L) return true + return System.currentTimeMillis() - state.lastPromptTime >= FIFTEEN_DAYS_MS + } + + fun dismissTemporarily() { + state.lastPromptTime = System.currentTimeMillis() + } + + class State( + var lastPromptTime: Long = 0L, + ) + + companion object { + fun getInstance(): CfnNodePromptState = service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPrompter.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPrompter.kt new file mode 100644 index 00000000000..fbbbc613bb2 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPrompter.kt @@ -0,0 +1,111 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.ide.BrowserUtil +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import software.aws.toolkits.jetbrains.settings.CfnLspSettings +import software.aws.toolkits.resources.AwsToolkitBundle.message + +private const val THIRTY_DAYS_MS = 30L * 24 * 60 * 60 * 1000 +private const val TELEMETRY_DOCS_URL = "https://github.com/aws-cloudformation/cloudformation-languageserver/tree/main/src/telemetry" + +@Service +@State(name = "cfnTelemetryPromptState", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnTelemetryPromptState : PersistentStateComponent { + private var state = State() + + override fun getState(): State = state + override fun loadState(state: State) { this.state = state } + + var hasResponded: Boolean + get() = state.hasResponded + set(value) { state.hasResponded = value } + + var lastPromptDate: Long + get() = state.lastPromptDate + set(value) { state.lastPromptDate = value } + + class State( + var hasResponded: Boolean = false, + var lastPromptDate: Long = 0L, + ) + + companion object { + fun getInstance(): CfnTelemetryPromptState = service() + } +} + +internal class CfnTelemetryPrompter : ProjectActivity { + override suspend fun execute(project: Project) { + val promptState = CfnTelemetryPromptState.getInstance() + + if (promptState.hasResponded) return + + val now = System.currentTimeMillis() + if (promptState.lastPromptDate != 0L && now - promptState.lastPromptDate < THIRTY_DAYS_MS) return + + showPrompt(project) + } + + private fun showPrompt(project: Project) { + val notification = Notification( + CfnLspExtensionConfig.TELEMETRY_NOTIFICATION_GROUP_ID, + message("cloudformation.telemetry.prompt.title"), + message("cloudformation.telemetry.prompt.message"), + NotificationType.INFORMATION + ) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.allow")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice(telemetryEnabled = true, permanent = true) + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.not_now")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice(telemetryEnabled = false, permanent = false) + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.never")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice(telemetryEnabled = false, permanent = true) + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.learn_more")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + BrowserUtil.browse(TELEMETRY_DOCS_URL) + } + }) + + notification.notify(project) + } + + private fun applyChoice(telemetryEnabled: Boolean, permanent: Boolean) { + val promptState = CfnTelemetryPromptState.getInstance() + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = permanent + promptState.lastPromptDate = System.currentTimeMillis() + + settings.isTelemetryEnabled = telemetryEnabled + settings.notifySettingsChanged() + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/AuthProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/AuthProtocol.kt new file mode 100644 index 00000000000..b7270cb71e7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/AuthProtocol.kt @@ -0,0 +1,13 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +internal data class UpdateCredentialsParams( + val data: String, + val encrypted: Boolean = true, +) + +internal data class UpdateCredentialsResult( + val success: Boolean, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/ResourceProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/ResourceProtocol.kt new file mode 100644 index 00000000000..3c165fda32b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/ResourceProtocol.kt @@ -0,0 +1,82 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +import org.eclipse.lsp4j.CompletionItem +import org.eclipse.lsp4j.TextDocumentIdentifier + +// Resource Types +internal data class ResourceTypesResult( + val resourceTypes: List, +) + +// Resource Listing +internal data class ResourceRequest( + val resourceType: String, + val nextToken: String? = null, +) + +internal data class ListResourcesParams( + val resources: List? = null, +) + +internal data class ResourceSummary( + val typeName: String, + val resourceIdentifiers: List, + val nextToken: String? = null, +) + +internal data class ListResourcesResult( + val resources: List, +) + +internal enum class ResourceStatePurpose(val value: String) { + IMPORT("Import"), + CLONE("Clone"), +} + +internal data class ResourceSelection( + val resourceType: String, + val resourceIdentifiers: List, +) + +internal data class ResourceStateParams( + val textDocument: TextDocumentIdentifier, + val resourceSelections: List? = null, + val purpose: String, + val parentResourceType: String? = null, +) + +internal data class ResourceStateResult( + val completionItem: CompletionItem? = null, + val successfulImports: Map>, + val failedImports: Map>, + val warning: String? = null, +) + +internal data class SearchResourceParams( + val resourceType: String, + val identifier: String, +) + +internal data class SearchResourceResult( + val found: Boolean, + val resource: ResourceSummary? = null, +) + +internal data class RefreshResourcesParams( + val resources: List, +) + +internal data class RefreshResourcesResult( + val resources: List, +) + +internal data class ResourceStackManagementResult( + val physicalResourceId: String, + val managedByStack: Boolean? = null, + val stackName: String? = null, + val stackId: String? = null, + val error: String? = null, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackActionProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackActionProtocol.kt new file mode 100644 index 00000000000..70f212aef6d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackActionProtocol.kt @@ -0,0 +1,300 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +import com.google.gson.annotations.SerializedName + +internal data class Identifiable(val id: String) + +internal data class Parameter( + @SerializedName("ParameterKey") + val parameterKey: String, + + @SerializedName("ParameterValue") + val parameterValue: String, +) + +internal data class Tag( + @SerializedName("Key") + val key: String, + + @SerializedName("Value") + val value: String, +) + +internal data class ResourceToImport( + @SerializedName("ResourceType") + val resourceType: String, + + @SerializedName("LogicalResourceId") + val logicalResourceId: String, + + @SerializedName("ResourceIdentifier") + val resourceIdentifier: Map, +) + +@Suppress("unused") // All values required for Gson deserialization +internal enum class StackActionPhase { + @SerializedName("VALIDATION_STARTED") VALIDATION_STARTED, + + @SerializedName("VALIDATION_IN_PROGRESS") VALIDATION_IN_PROGRESS, + + @SerializedName("VALIDATION_COMPLETE") VALIDATION_COMPLETE, + + @SerializedName("VALIDATION_FAILED") VALIDATION_FAILED, + + @SerializedName("DEPLOYMENT_STARTED") DEPLOYMENT_STARTED, + + @SerializedName("DEPLOYMENT_IN_PROGRESS") DEPLOYMENT_IN_PROGRESS, + + @SerializedName("DEPLOYMENT_COMPLETE") DEPLOYMENT_COMPLETE, + + @SerializedName("DEPLOYMENT_FAILED") DEPLOYMENT_FAILED, + + @SerializedName("DELETION_STARTED") DELETION_STARTED, + + @SerializedName("DELETION_IN_PROGRESS") DELETION_IN_PROGRESS, + + @SerializedName("DELETION_COMPLETE") DELETION_COMPLETE, + + @SerializedName("DELETION_FAILED") DELETION_FAILED, +} + +@Suppress("unused") // All values required for Gson deserialization +internal enum class StackActionState { + @SerializedName("IN_PROGRESS") IN_PROGRESS, + + @SerializedName("SUCCESSFUL") SUCCESSFUL, + + @SerializedName("FAILED") FAILED, +} + +internal enum class DeploymentMode { + @SerializedName("REVERT_DRIFT") REVERT_DRIFT, +} + +internal data class CreateValidationParams( + val id: String, + val uri: String, + val stackName: String, + val parameters: List? = null, + val capabilities: List? = null, + val resourcesToImport: List? = null, + val keepChangeSet: Boolean? = null, + val onStackFailure: String? = null, + val includeNestedStacks: Boolean? = null, + val tags: List? = null, + val importExistingResources: Boolean? = null, + val deploymentMode: DeploymentMode? = null, + val s3Bucket: String? = null, + val s3Key: String? = null, +) + +internal data class CreateStackActionResult( + val id: String, + val changeSetName: String, + val stackName: String, +) + +internal data class GetStackActionStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + val changes: List? = null, +) + +internal data class ValidationDetail( + @SerializedName("ValidationName") + val validationName: String, + @SerializedName("LogicalId") + val logicalId: String? = null, + @SerializedName("ResourcePropertyPath") + val resourcePropertyPath: String? = null, + @SerializedName("Severity") + val severity: String, + @SerializedName("Message") + val message: String, +) + +internal data class DescribeValidationStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + val changes: List? = null, + @SerializedName("ValidationDetails") + val validationDetails: List? = null, + @SerializedName("FailureReason") + val failureReason: String? = null, + val deploymentMode: DeploymentMode? = null, +) + +internal data class StackChange( + val type: String? = null, + val resourceChange: ResourceChange? = null, +) + +internal data class ResourceChange( + val action: String? = null, + val logicalResourceId: String? = null, + val physicalResourceId: String? = null, + val resourceType: String? = null, + val replacement: String? = null, + val scope: List? = null, + val beforeContext: String? = null, + val afterContext: String? = null, + val resourceDriftStatus: String? = null, + val details: List? = null, +) + +internal data class ResourceChangeDetail( + @SerializedName("Target") + val target: ResourceTargetDefinition? = null, + @SerializedName("Evaluation") + val evaluation: String? = null, + @SerializedName("ChangeSource") + val changeSource: String? = null, + @SerializedName("CausingEntity") + val causingEntity: String? = null, +) + +internal data class ResourceTargetDefinition( + @SerializedName("Attribute") + val attribute: String? = null, + @SerializedName("Name") + val name: String? = null, + @SerializedName("RequiresRecreation") + val requiresRecreation: String? = null, + @SerializedName("Path") + val path: String? = null, + @SerializedName("BeforeValue") + val beforeValue: String? = null, + @SerializedName("AfterValue") + val afterValue: String? = null, + @SerializedName("AttributeChangeType") + val attributeChangeType: String? = null, + @SerializedName("Drift") + val drift: DriftInfo? = null, + @SerializedName("LiveResourceDrift") + val liveResourceDrift: DriftInfo? = null, +) + +internal data class DriftInfo( + @SerializedName("PreviousValue") + val previousValue: String? = null, + @SerializedName("ActualValue") + val actualValue: String? = null, +) + +internal data class DescribeChangeSetParams( + val changeSetName: String, + val stackName: String, +) + +internal data class DescribeChangeSetResult( + val changeSetName: String, + val stackName: String, + val status: String, + val creationTime: String? = null, + val description: String? = null, + val changes: List? = null, + val deploymentMode: DeploymentMode? = null, +) + +// Deployment + +internal data class CreateDeploymentParams( + val id: String, + val changeSetName: String, + val stackName: String, +) + +internal data class DeploymentEvent( + @SerializedName("LogicalResourceId") + val logicalResourceId: String? = null, + @SerializedName("ResourceType") + val resourceType: String? = null, + @SerializedName("ResourceStatus") + val resourceStatus: String? = null, + @SerializedName("ResourceStatusReason") + val resourceStatusReason: String? = null, +) + +internal data class DescribeDeploymentStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + val changes: List? = null, + @SerializedName("DeploymentEvents") + val deploymentEvents: List? = null, + @SerializedName("FailureReason") + val failureReason: String? = null, +) + +// Change set deletion + +internal data class DeleteChangeSetParams( + val id: String, + val changeSetName: String, + val stackName: String, +) + +internal data class DescribeDeletionStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + @SerializedName("FailureReason") + val failureReason: String? = null, +) + +// Template analysis + +internal data class TemplateParameter( + val name: String, + @SerializedName("Type") + val type: String? = null, + @SerializedName("Default") + val default: Any? = null, + @SerializedName("Description") + val description: String? = null, + @SerializedName("AllowedValues") + val allowedValues: List? = null, + @SerializedName("AllowedPattern") + val allowedPattern: String? = null, + @SerializedName("MinLength") + val minLength: Int? = null, + @SerializedName("MaxLength") + val maxLength: Int? = null, + @SerializedName("MinValue") + val minValue: Number? = null, + @SerializedName("MaxValue") + val maxValue: Number? = null, +) + +internal data class GetParametersResult( + val parameters: List, +) + +internal data class GetCapabilitiesResult( + val capabilities: List, +) + +internal data class TemplateResource( + val logicalId: String, + val type: String, + val primaryIdentifierKeys: List? = null, + val primaryIdentifier: Map? = null, +) + +internal data class GetTemplateResourcesResult( + val resources: List, +) + +internal data class Artifact( + val resourceType: String, + val filePath: String, +) + +internal data class GetTemplateArtifactsResult( + val artifacts: List, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt new file mode 100644 index 00000000000..806d6ec8c88 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt @@ -0,0 +1,162 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +import com.google.gson.annotations.SerializedName + +internal data class ListStacksParams( + val statusToExclude: List? = null, + val loadMore: Boolean = false, +) + +internal data class StackSummary( + @SerializedName("StackName") val stackName: String? = null, + @SerializedName("StackId") val stackId: String? = null, + @SerializedName("StackStatus") val stackStatus: String? = null, + @SerializedName("CreationTime") val creationTime: String? = null, + @SerializedName("LastUpdateTime") val lastUpdatedTime: String? = null, + @SerializedName("TemplateDescription") val templateDescription: String? = null, +) + +internal data class ListStacksResult( + val stacks: List, + val nextToken: String? = null, +) + +internal data class ListChangeSetsParams( + val stackName: String, + val nextToken: String? = null, +) + +internal data class ChangeSetInfo( + val changeSetName: String, + val status: String, + val creationTime: String? = null, + val description: String? = null, +) + +internal data class ListChangeSetsResult( + val changeSets: List, + val nextToken: String? = null, +) + +internal data class DescribeStackParams( + val stackName: String, +) + +internal data class DescribeStackResult( + val stack: StackDetail?, +) + +internal data class StackDetail( + @SerializedName("StackName") + val stackName: String, + @SerializedName("StackId") + val stackId: String, + @SerializedName("StackStatus") + val stackStatus: String, + @SerializedName("StackStatusReason") + val stackStatusReason: String? = null, + @SerializedName("CreationTime") + val creationTime: String? = null, + @SerializedName("LastUpdatedTime") + val lastUpdatedTime: String? = null, + @SerializedName("Description") + val description: String? = null, + @SerializedName("Outputs") + val outputs: List? = null, + @SerializedName("Parameters") + val parameters: List? = null, + @SerializedName("Tags") + val tags: List? = null, +) + +internal data class StackOutput( + @SerializedName("OutputKey") + val outputKey: String, + @SerializedName("OutputValue") + val outputValue: String, + @SerializedName("Description") + val description: String? = null, + @SerializedName("ExportName") + val exportName: String? = null, +) + +// Stack Resources Protocol +internal data class GetStackResourcesParams( + val stackName: String, + val nextToken: String? = null, +) + +internal data class StackResourceSummary( + @SerializedName("LogicalResourceId") + val logicalResourceId: String, + @SerializedName("PhysicalResourceId") + val physicalResourceId: String?, + @SerializedName("ResourceType") + val resourceType: String, + @SerializedName("ResourceStatus") + val resourceStatus: String, + @SerializedName("Timestamp") + val timestamp: String?, +) + +internal data class ListStackResourcesResult( + val resources: List, + val nextToken: String? = null, +) + +// Stack Events Protocol +internal data class GetStackEventsParams( + val stackName: String, + val nextToken: String? = null, + val refresh: Boolean? = null, +) + +internal data class GetStackEventsResult( + val events: List, + val nextToken: String? = null, + val gapDetected: Boolean? = null, +) + +internal data class StackEvent( + @SerializedName("StackId") + val stackId: String? = null, + @SerializedName("EventId") + val eventId: String? = null, + @SerializedName("StackName") + val stackName: String? = null, + @SerializedName("LogicalResourceId") + val logicalResourceId: String? = null, + @SerializedName("PhysicalResourceId") + val physicalResourceId: String? = null, + @SerializedName("ResourceType") + val resourceType: String? = null, + @SerializedName("Timestamp") + val timestamp: String? = null, + @SerializedName("ResourceStatus") + val resourceStatus: String? = null, + @SerializedName("ResourceStatusReason") + val resourceStatusReason: String? = null, + @SerializedName("ResourceProperties") + val resourceProperties: String? = null, + @SerializedName("ClientRequestToken") + val clientRequestToken: String? = null, + @SerializedName("OperationId") + val operationId: String? = null, + @SerializedName("HookType") + val hookType: String? = null, + @SerializedName("HookStatus") + val hookStatus: String? = null, + @SerializedName("HookStatusReason") + val hookStatusReason: String? = null, + @SerializedName("HookInvocationPoint") + val hookInvocationPoint: String? = null, + @SerializedName("HookFailureMode") + val hookFailureMode: String? = null, +) + +internal data class ClearStackEventsParams( + val stackName: String, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspException.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspException.kt new file mode 100644 index 00000000000..5175fcebc56 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspException.kt @@ -0,0 +1,20 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +internal class CfnLspException( + message: String, + val errorCode: ErrorCode, + cause: Throwable? = null, +) : Exception(message, cause) { + + enum class ErrorCode { + MANIFEST_FETCH_FAILED, + NO_COMPATIBLE_VERSION, + DOWNLOAD_FAILED, + EXTRACTION_FAILED, + NODE_NOT_FOUND, + HASH_VERIFICATION_FAILED, + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt new file mode 100644 index 00000000000..9ca2f047c0a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt @@ -0,0 +1,247 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.ide.util.PropertiesComponent +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.lsp.getToolkitsCacheRoot +import software.aws.toolkits.jetbrains.utils.ZipDecompressor +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import kotlin.io.path.isDirectory + +internal class CfnLspInstaller( + private val storageDir: Path = defaultStorageDir(), + private val manifestAdapter: GitHubManifestAdapter = GitHubManifestAdapter(CfnLspEnvironment.fromEnvironment()), +) { + private val httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(java.time.Duration.ofSeconds(30)) + .build() + + private val versionRange = SemVerRange.parse(CfnLspServerConfig.SUPPORTED_VERSION_RANGE) + + fun getServerPath(): Path { + val release = try { + manifestAdapter.getLatestRelease().also { saveManifestCache() } + } catch (e: Exception) { + LOG.warn(e) { "Failed to fetch manifest, trying cached manifest" } + tryFromCachedManifest() ?: run { + LOG.warn { "No cached manifest, trying cached server" } + return findCachedServer() ?: throw CfnLspException( + message("cloudformation.lsp.error.manifest_failed"), + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED, + e + ) + } + } + + val versionDir = storageDir.resolve(release.version) + val serverPath = versionDir.resolve(CfnLspServerConfig.SERVER_FILE) + + cleanupLegacyStorageDir() + + return if (Files.exists(serverPath)) { + LOG.info { "Using cached CloudFormation LSP ${release.version}" } + serverPath + } else { + downloadAndInstall(release).also { cleanupOldVersions(release.version) } + } + } + + private fun tryFromCachedManifest(): ServerRelease? { + val cached = loadManifestCache() ?: return null + return try { + LOG.debug { "Using cached manifest for offline mode" } + manifestAdapter.parseManifest(cached) + } catch (e: Exception) { + LOG.warn(e) { "Failed to parse cached manifest" } + null + } + } + + /** + * Finds the highest compatible cached server version. + * Uses semver comparison to pick the best available fallback. + */ + private fun findCachedServer(): Path? { + if (!Files.exists(storageDir)) return null + + return Files.list(storageDir).use { stream -> + stream.toList() + .filter { it.isDirectory() } + .filter { Files.exists(it.resolve(CfnLspServerConfig.SERVER_FILE)) } + .mapNotNull { dir -> SemVer.parse(dir.fileName.toString())?.let { dir to it } } + .filter { (_, ver) -> versionRange.satisfiedBy(ver) } + .maxByOrNull { (_, ver) -> ver } + ?.first + ?.resolve(CfnLspServerConfig.SERVER_FILE) + ?.also { LOG.info { "Using fallback cached server: $it" } } + } + } + + private fun downloadAndInstall(release: ServerRelease): Path { + LOG.info { "Downloading CloudFormation LSP ${release.version}" } + + val zipBytes = try { + downloadAsset(release.downloadUrl) + } catch (e: Exception) { + LOG.error(e) { "Failed to download CloudFormation LSP" } + throw CfnLspException( + message("cloudformation.lsp.error.download_failed"), + CfnLspException.ErrorCode.DOWNLOAD_FAILED, + e + ) + } + + // Verify hash if available + if (release.hashes.isNotEmpty()) { + verifyHash(zipBytes, release.hashes) + } + + val targetDir = storageDir.resolve(release.version) + try { + Files.createDirectories(targetDir) + ZipDecompressor(zipBytes).use { it.extract(targetDir.toFile()) } + } catch (e: Exception) { + LOG.error(e) { "Failed to extract CloudFormation LSP" } + throw CfnLspException( + message("cloudformation.lsp.error.extraction_failed"), + CfnLspException.ErrorCode.EXTRACTION_FAILED, + e + ) + } + + val serverPath = targetDir.resolve(CfnLspServerConfig.SERVER_FILE) + LOG.info { "CloudFormation LSP installed to: $serverPath" } + return serverPath + } + + private fun verifyHash(data: ByteArray, expectedHashes: List) { + for (expected in expectedHashes) { + val (algorithm, hash) = parseHashString(expected) ?: continue + val computed = computeHash(data, algorithm) + if (computed.equals(hash, ignoreCase = true)) { + LOG.debug { "Hash verification passed ($algorithm)" } + return + } + LOG.warn { "Hash mismatch for $algorithm: expected $hash, got $computed" } + } + if (expectedHashes.isNotEmpty()) { + throw CfnLspException( + message("cloudformation.lsp.error.hash_mismatch"), + CfnLspException.ErrorCode.HASH_VERIFICATION_FAILED + ) + } + } + + private fun cleanupLegacyStorageDir() { + val legacyDir = getToolkitsCacheRoot().resolve("cloudformation-lsp") + if (!Files.exists(legacyDir)) return + try { + legacyDir.toFile().deleteRecursively() + LOG.info { "Removed legacy LSP directory: $legacyDir" } + } catch (e: Exception) { + LOG.warn(e) { "Failed to remove legacy LSP directory" } + } + } + + /** + * Removes old versions, keeping the current version and one compatible fallback. + */ + private fun cleanupOldVersions(currentVersion: String) { + if (!Files.exists(storageDir)) return + + try { + val dirs = Files.list(storageDir).use { stream -> + stream.filter { it.isDirectory() }.toList() + } + + // Keep the highest compatible version other than current as fallback + val fallbackDir = dirs + .filter { it.fileName.toString() != currentVersion } + .mapNotNull { dir -> SemVer.parse(dir.fileName.toString())?.let { dir to it } } + .filter { (_, ver) -> versionRange.satisfiedBy(ver) } + .maxByOrNull { (_, ver) -> ver } + ?.first + + val keep = setOfNotNull(currentVersion, fallbackDir?.fileName?.toString()) + + dirs.filter { it.fileName.toString() !in keep } + .forEach { oldDir -> + LOG.debug { "Removing old LSP version: ${oldDir.fileName}" } + oldDir.toFile().deleteRecursively() + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to cleanup old LSP versions" } + } + } + + private fun downloadAsset(url: String): ByteArray { + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(java.time.Duration.ofMinutes(5)) + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()) + + if (response.statusCode() != 200) { + throw RuntimeException("HTTP ${response.statusCode()}") + } + + return response.body() + } + + private fun saveManifestCache() { + val json = manifestAdapter.getCachedManifest() ?: return + try { + PropertiesComponent.getInstance().setValue(MANIFEST_CACHE_KEY, json) + } catch (e: Exception) { + LOG.debug { "Failed to save manifest cache: ${e.message}" } + } + } + + private fun loadManifestCache(): String? = try { + PropertiesComponent.getInstance().getValue(MANIFEST_CACHE_KEY) + } catch (e: Exception) { + LOG.debug { "Failed to load manifest cache: ${e.message}" } + null + } + + companion object { + private val LOG = getLogger() + private const val MANIFEST_CACHE_KEY = "aws.cloudformation.lsp.manifest" + + fun defaultStorageDir(): Path = getToolkitsCacheRoot().resolve("language-servers").resolve("cloudformation-languageserver") + + internal fun parseHashString(hashString: String): Pair? { + // Format: "sha256:abc123..." or "sha384:abc123..." + val parts = hashString.split(":", limit = 2) + return if (parts.size == 2) parts[0] to parts[1] else null + } + + internal fun computeHash(data: ByteArray, algorithm: String): String { + val digestAlgorithm = when (algorithm.lowercase()) { + "sha256" -> "SHA-256" + "sha384" -> "SHA-384" + "sha512" -> "SHA-512" + else -> algorithm.uppercase() + } + return MessageDigest.getInstance(digestAlgorithm) + .digest(data) + .joinToString("") { "%02x".format(it) } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt new file mode 100644 index 00000000000..cff71e77e74 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt @@ -0,0 +1,26 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +internal object CfnLspServerConfig { + const val SERVER_FILE = "cfn-lsp-server-standalone.js" + const val GITHUB_OWNER = "aws-cloudformation" + const val GITHUB_REPO = "cloudformation-languageserver" + + /** Semver range constraint for compatible language server versions. */ + const val SUPPORTED_VERSION_RANGE = "<2.0.0" +} + +internal enum class CfnLspEnvironment { + ALPHA, BETA, PROD; + + companion object { + fun fromEnvironment(): CfnLspEnvironment = + if (System.getenv("AWS_TOOLKIT_AUTOMATION")?.equals("true", ignoreCase = true) == true) { + BETA + } else { + PROD + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt new file mode 100644 index 00000000000..584e7006675 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt @@ -0,0 +1,161 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.util.SystemInfo +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.lsp.getCurrentArchitecture +import software.aws.toolkits.jetbrains.core.lsp.getCurrentOS +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +internal data class ManifestVersion( + val serverVersion: String, + val latest: Boolean = false, + val isDelisted: Boolean = false, + val targets: List, +) + +internal data class ManifestTarget( + val platform: String, + val arch: String, + val nodejs: String? = null, + val contents: List, +) + +internal data class ManifestContent( + val filename: String, + val url: String, + val hashes: List = emptyList(), + val bytes: Long, +) + +internal data class ServerRelease( + val version: String, + val downloadUrl: String, + val filename: String, + val size: Long, + val hashes: List = emptyList(), +) + +internal class GitHubManifestAdapter( + private val environment: CfnLspEnvironment, + private val versionRange: SemVerRange = SemVerRange.parse(CfnLspServerConfig.SUPPORTED_VERSION_RANGE), + private val legacyLinuxDetector: LegacyLinuxDetector = LegacyLinuxDetector(), +) { + private val httpClient = HttpClient.newBuilder().build() + private val mapper = jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + @Volatile + private var cachedManifestJson: String? = null + + fun getLatestRelease(): ServerRelease { + val manifestJson = fetchManifestJson() + cachedManifestJson = manifestJson + return parseManifest(manifestJson) + } + + internal fun parseManifest(json: String): ServerRelease { + val root = mapper.readTree(json) + val envKey = environment.name.lowercase() + var versions: List = mapper.readValue(root.get(envKey).toString()) + + if (SystemInfo.isLinux && legacyLinuxDetector.useLegacyLinux()) { + LOG.info { "Legacy Linux environment detected, using $LEGACY_LINUX_PLATFORM builds" } + versions = remapLegacyLinux(versions) + } + + LOG.info { + "Candidate versions for $environment: ${versions.joinToString { v -> + "${v.serverVersion}[${v.targets.joinToString(",") { "${it.platform}-${it.arch}" }}]" + }}" + } + + val version = latestCompatibleVersion(versions) + + val platform = getCurrentOS() + val arch = getCurrentArchitecture() + + val target = version.targets.firstOrNull { it.platform == platform && it.arch == arch } + ?: error("No target found for $platform-$arch") + + val content = target.contents.firstOrNull() + ?: error("No content found for $platform-$arch") + + LOG.info { "Selected ${version.serverVersion} for $platform-$arch" } + + return ServerRelease( + version = version.serverVersion, + downloadUrl = content.url, + filename = content.filename, + size = content.bytes, + hashes = content.hashes + ) + } + + /** + * Selects the best compatible version from the manifest. + * Prefers the version marked as "latest" if it's compatible, otherwise falls back to + * the highest compatible version by semver. + */ + private fun latestCompatibleVersion(versions: List): ManifestVersion { + val compatible = versions + .filter { !it.isDelisted } + .mapNotNull { v -> SemVer.parse(v.serverVersion)?.let { v to it } } + .filter { (_, semver) -> versionRange.satisfiedBy(semver) } + + // Prefer the version explicitly marked as latest + val markedLatest = compatible.firstOrNull { (v, _) -> v.latest } + if (markedLatest != null) return markedLatest.first + + // Fall back to the highest by semver + return compatible + .maxByOrNull { (_, semver) -> semver } + ?.first + ?: error("No compatible version found for range ${CfnLspServerConfig.SUPPORTED_VERSION_RANGE} in $environment") + } + + private fun fetchManifestJson(): String { + val url = "https://raw.githubusercontent.com/${CfnLspServerConfig.GITHUB_OWNER}/${CfnLspServerConfig.GITHUB_REPO}/main/assets/release-manifest.json" + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) { + error("Manifest fetch error: ${response.statusCode()}") + } + return response.body() + } + + fun getCachedManifest(): String? = cachedManifestJson + + companion object { + private val LOG = getLogger() + private const val LEGACY_LINUX_PLATFORM = "linuxglib2.28" + + internal fun remapLegacyLinux(versions: List): List = + versions.map { version -> + val hasLegacy = version.targets.any { it.platform == LEGACY_LINUX_PLATFORM } + if (!hasLegacy) { + LOG.warn { "No legacy Linux build for ${version.serverVersion}" } + return@map version + } + version.copy( + targets = version.targets + .filter { it.platform != "linux" } + .map { if (it.platform == LEGACY_LINUX_PLATFORM) it.copy(platform = "linux") else it } + ) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetector.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetector.kt new file mode 100644 index 00000000000..a5919135f54 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetector.kt @@ -0,0 +1,124 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.openapi.util.SystemInfo +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Detects legacy Linux environments that require older glibc-compatible builds. + */ +internal class LegacyLinuxDetector { + private val glibcxxThreshold = listOf(3, 4, 29) // GLIBCXX_3.4.29 + + fun useLegacyLinux(): Boolean { + if (!SystemInfo.isLinux) return false + + // Check for Snap environment + if (System.getenv("SNAP") != null) { + LOG.info { "Snap environment detected" } + return true + } + + val maxVersion = getMaxGlibcxxVersion() ?: return false + val isLegacy = compareVersions(maxVersion, glibcxxThreshold) < 0 + + if (isLegacy) { + LOG.info { "GLIBCXX $maxVersion < 3.4.29, using legacy Linux build" } + } + return isLegacy + } + + internal fun getMaxGlibcxxVersion(): List? { + val libPath = findLibStdCpp() ?: return null + + val output = extractGlibcxxStringsUsingStringsCommand(libPath) + ?: extractGlibcxxStringsFromBinaryFile(libPath) + if (output == null) { + LOG.warn { "Failed to read GLIBCXX versions from $libPath" } + return null + } + + return parseGlibcxxVersions(output).maxWithOrNull(::compareVersions) + } + + private fun extractGlibcxxStringsUsingStringsCommand(libPath: String): String? = try { + val process = ProcessBuilder("strings", libPath) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + if (process.waitFor(10, TimeUnit.SECONDS) && output.contains("GLIBCXX")) output else null + } catch (_: Exception) { + LOG.info { "strings command failed, trying binary read fallback" } + null + } + + private fun extractGlibcxxStringsFromBinaryFile(libPath: String): String? = try { + val bytes = File(libPath).readBytes() + val content = String(bytes, Charsets.ISO_8859_1) + val matches = Regex("""GLIBCXX_\d+\.\d+(?:\.\d+)?""").findAll(content) + matches.joinToString("\n") { it.value }.ifEmpty { null } + } catch (e: Exception) { + LOG.warn(e) { "Failed to read binary at $libPath" } + null + } + + internal fun parseGlibcxxVersions(output: String): List> = + Regex("""GLIBCXX_(\d+\.\d+(?:\.\d+)?)""") + .findAll(output) + .map { it.groupValues[1].split(".").map(String::toInt) } + .toList() + + private fun findLibStdCpp(): String? { + // Try ldconfig first (most reliable on standard Linux) + findLibStdCppUsingLdconfig()?.let { return it } + + // Fallback to common paths + val commonPaths = listOf( + "/usr/lib/x86_64-linux-gnu/libstdc++.so.6", + "/usr/lib64/libstdc++.so.6", + "/usr/lib/libstdc++.so.6", + "/lib/x86_64-linux-gnu/libstdc++.so.6", + ) + return commonPaths.firstOrNull { File(it).exists() } + } + + private fun findLibStdCppUsingLdconfig(): String? = try { + val process = ProcessBuilder("/sbin/ldconfig", "-p") + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + if (!process.waitFor(5, TimeUnit.SECONDS)) { + null + } else { + // Parse: "libstdc++.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstdc++.so.6" + output.lineSequence() + .filter { it.contains("libstdc++.so.6") } + .firstNotNullOfOrNull { line -> + Regex("""=>\s+(.+)$""").find(line)?.groupValues?.get(1)?.trim() + } + } + } catch (_: Exception) { + null + } + + companion object { + private val LOG = getLogger() + + internal fun compareVersions(a: List, b: List): Int { + for (i in 0 until maxOf(a.size, b.size)) { + val partA = a.getOrElse(i) { 0 } + val partB = b.getOrElse(i) { 0 } + if (partA != partB) return partA.compareTo(partB) + } + return 0 + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVer.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVer.kt new file mode 100644 index 00000000000..4f1148ac147 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVer.kt @@ -0,0 +1,101 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +/** + * Minimal semver implementation for comparing language server versions. + * Handles formats: "1.4.0", "v1.4.0", "1.4.0-beta" + */ +internal data class SemVer( + val major: Int, + val minor: Int, + val patch: Int, + val prerelease: List = emptyList(), +) : Comparable { + + override fun compareTo(other: SemVer): Int { + major.compareTo(other.major).let { if (it != 0) return it } + minor.compareTo(other.minor).let { if (it != 0) return it } + patch.compareTo(other.patch).let { if (it != 0) return it } + + // No prerelease > has prerelease (1.0.0 > 1.0.0-beta) + if (prerelease.isEmpty() && other.prerelease.isNotEmpty()) return 1 + if (prerelease.isNotEmpty() && other.prerelease.isEmpty()) return -1 + + // Compare prerelease identifiers left to right + for (i in 0 until maxOf(prerelease.size, other.prerelease.size)) { + val thisId = prerelease.getOrNull(i) + val otherId = other.prerelease.getOrNull(i) + if (thisId == null) return -1 // fewer fields = lower precedence + if (otherId == null) return 1 + val thisNum = thisId.toIntOrNull() + val otherNum = otherId.toIntOrNull() + val result = when { + thisNum != null && otherNum != null -> thisNum.compareTo(otherNum) + thisNum != null -> -1 // numeric < string + otherNum != null -> 1 + else -> thisId.compareTo(otherId) + } + if (result != 0) return result + } + return 0 + } + + companion object { + fun parse(version: String): SemVer? { + val cleaned = version.removePrefix("v") + val hyphenIdx = cleaned.indexOf('-') + val core = if (hyphenIdx >= 0) cleaned.substring(0, hyphenIdx) else cleaned + val prePart = if (hyphenIdx >= 0) cleaned.substring(hyphenIdx + 1) else null + + val parts = core.split('.') + if (parts.size != 3) return null + + val major = parts[0].toIntOrNull() ?: return null + val minor = parts[1].toIntOrNull() ?: return null + val patch = parts[2].toIntOrNull() ?: return null + val prerelease = prePart?.split('-').orEmpty() + + return SemVer(major, minor, patch, prerelease) + } + } +} + +/** + * Simple version range supporting constraints like "<2.0.0". + * Supports: =X.Y.Z, >X.Y.Z + */ +internal class SemVerRange private constructor( + private val constraints: List, +) { + private data class Constraint(val op: String, val version: SemVer) + + fun satisfiedBy(version: SemVer): Boolean = + constraints.all { constraint -> + val coreVersion = SemVer(version.major, version.minor, version.patch) + when (constraint.op) { + "<" -> coreVersion < constraint.version + "<=" -> coreVersion <= constraint.version + ">" -> coreVersion > constraint.version + ">=" -> coreVersion >= constraint.version + else -> false + } + } + + companion object { + private val CONSTRAINT_PATTERN = Regex("""(<=?|>=?)\s*(\S+)""") + + fun parse(range: String): SemVerRange { + val constraints = CONSTRAINT_PATTERN.findAll(range).map { match -> + val op = match.groupValues[1] + val ver = SemVer.parse(match.groupValues[2]) + ?: error("Invalid version in range: ${match.groupValues[2]}") + Constraint(op, ver) + }.toList() + + require(constraints.isNotEmpty()) { "Invalid version range: $range" } + return SemVerRange(constraints) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettings.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettings.kt new file mode 100644 index 00000000000..83ab3d9e38e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettings.kt @@ -0,0 +1,199 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.util.messages.Topic + +fun interface CfnLspSettingsChangeListener { + fun settingsChanged() + + companion object { + val TOPIC = Topic.create("CFN LSP Settings Changed", CfnLspSettingsChangeListener::class.java) + } +} + +@Service +@State(name = "cfnLspSettings", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnLspSettings : PersistentStateComponent { + private var state = State() + + override fun getState(): State = state + override fun loadState(state: State) { this.state = state } + + fun notifySettingsChanged() { + ApplicationManager.getApplication().messageBus + .syncPublisher(CfnLspSettingsChangeListener.TOPIC) + .settingsChanged() + } + + var nodeRuntimePath: String + get() = state.nodeRuntimePath + set(value) { state.nodeRuntimePath = value } + + var isTelemetryEnabled: Boolean + get() = state.isTelemetryEnabled + set(value) { state.isTelemetryEnabled = value } + + var isHoverEnabled: Boolean + get() = state.isHoverEnabled + set(value) { state.isHoverEnabled = value } + + var isCompletionEnabled: Boolean + get() = state.isCompletionEnabled + set(value) { state.isCompletionEnabled = value } + + var maxCompletions: Int + get() = state.maxCompletions + set(value) { state.maxCompletions = value } + + // CFN-Lint settings + var isCfnLintEnabled: Boolean + get() = state.isCfnLintEnabled + set(value) { state.isCfnLintEnabled = value } + + var cfnLintLintOnChange: Boolean + get() = state.cfnLintLintOnChange + set(value) { state.cfnLintLintOnChange = value } + + var cfnLintDelayMs: Int + get() = state.cfnLintDelayMs + set(value) { state.cfnLintDelayMs = value } + + var cfnLintPath: String + get() = state.cfnLintPath + set(value) { state.cfnLintPath = value } + + var cfnLintIgnoreChecks: String + get() = state.cfnLintIgnoreChecks + set(value) { state.cfnLintIgnoreChecks = value } + + var cfnLintIncludeChecks: String + get() = state.cfnLintIncludeChecks + set(value) { state.cfnLintIncludeChecks = value } + + var cfnLintIncludeExperimental: Boolean + get() = state.cfnLintIncludeExperimental + set(value) { state.cfnLintIncludeExperimental = value } + + var cfnLintCustomRules: String + get() = state.cfnLintCustomRules + set(value) { state.cfnLintCustomRules = value } + + var cfnLintAppendRules: String + get() = state.cfnLintAppendRules + set(value) { state.cfnLintAppendRules = value } + + var cfnLintOverrideSpec: String + get() = state.cfnLintOverrideSpec + set(value) { state.cfnLintOverrideSpec = value } + + var cfnLintRegistrySchemas: String + get() = state.cfnLintRegistrySchemas + set(value) { state.cfnLintRegistrySchemas = value } + + // CFN-Guard settings + var isCfnGuardEnabled: Boolean + get() = state.isCfnGuardEnabled + set(value) { state.isCfnGuardEnabled = value } + + var cfnGuardValidateOnChange: Boolean + get() = state.cfnGuardValidateOnChange + set(value) { state.cfnGuardValidateOnChange = value } + + var cfnGuardEnabledRulePacks: String + get() = state.cfnGuardEnabledRulePacks + set(value) { state.cfnGuardEnabledRulePacks = value } + + var cfnGuardRulesFile: String + get() = state.cfnGuardRulesFile + set(value) { state.cfnGuardRulesFile = value } + + data class State( + var nodeRuntimePath: String = "", + var isTelemetryEnabled: Boolean = false, + var isHoverEnabled: Boolean = true, + var isCompletionEnabled: Boolean = true, + var maxCompletions: Int = 100, + // CFN-Lint + var isCfnLintEnabled: Boolean = true, + var cfnLintLintOnChange: Boolean = true, + var cfnLintDelayMs: Int = 3000, + var cfnLintPath: String = "", + var cfnLintIgnoreChecks: String = "", + var cfnLintIncludeChecks: String = "I", + var cfnLintIncludeExperimental: Boolean = false, + var cfnLintCustomRules: String = "", + var cfnLintAppendRules: String = "", + var cfnLintOverrideSpec: String = "", + var cfnLintRegistrySchemas: String = "", + // CFN-Guard + var isCfnGuardEnabled: Boolean = true, + var cfnGuardValidateOnChange: Boolean = true, + var cfnGuardEnabledRulePacks: String = "wa-Security-Pillar", + var cfnGuardRulesFile: String = "", + ) + + companion object { + fun getInstance(): CfnLspSettings = service() + + val GUARD_RULE_PACKS = listOf( + "ABS-CCIGv2-Material", + "ABS-CCIGv2-Standard", + "acsc-essential-8", + "acsc-ism", + "apra-cpg-234", + "bnm-rmit", + "cis-aws-benchmark-level-1", + "cis-aws-benchmark-level-2", + "cis-critical-security-controls-v8-ig1", + "cis-critical-security-controls-v8-ig2", + "cis-critical-security-controls-v8-ig3", + "cis-top-20", + "cisa-ce", + "cmmc-level-1", + "cmmc-level-2", + "cmmc-level-3", + "cmmc-level-4", + "cmmc-level-5", + "enisa-cybersecurity-guide-for-smes", + "ens-high", + "ens-low", + "ens-medium", + "FDA-21CFR-Part-11", + "FedRAMP-Low", + "FedRAMP-Moderate", + "ffiec", + "hipaa-security", + "K-ISMS", + "mas-notice-655", + "mas-trmg", + "nbc-trmg", + "ncsc-cafv3", + "ncsc", + "nerc", + "nist-1800-25", + "nist-800-171", + "nist-800-172", + "nist-800-181", + "nist-csf", + "nist-privacy-framework", + "NIST800-53Rev4", + "NIST800-53Rev5", + "nzism", + "PCI-DSS-3-2-1", + "rbi-bcsf-ucb", + "rbi-md-itf", + "us-nydfs", + "wa-Reliability-Pillar", + "wa-Security-Pillar" + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettingsConfigurable.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettingsConfigurable.kt new file mode 100644 index 00000000000..1e8f8a5ee76 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettingsConfigurable.kt @@ -0,0 +1,181 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.ui.CheckBoxList +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.dsl.builder.bindIntText +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBUI +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class CfnLspSettingsConfigurable : BoundConfigurable(message("cloudformation.settings.title")), SearchableConfigurable { + private val settings = CfnLspSettings.getInstance() + + override fun createPanel() = panel { + group(message("cloudformation.settings.general.group")) { + row(message("cloudformation.settings.node.path")) { + textFieldWithBrowseButton( + FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle(message("cloudformation.settings.node.path.browse")) + ).bindText(settings::nodeRuntimePath).columns(30).comment(message("cloudformation.settings.node.path.comment")) + } + row { + checkBox(message("cloudformation.settings.telemetry.enable")).bindSelected(settings::isTelemetryEnabled) + } + } + + group(message("cloudformation.settings.hover.group")) { + row { + checkBox(message("cloudformation.settings.hover.enable")).bindSelected(settings::isHoverEnabled) + } + } + + group(message("cloudformation.settings.completion.group")) { + row { + checkBox(message("cloudformation.settings.completion.enable")).bindSelected(settings::isCompletionEnabled) + } + row(message("cloudformation.settings.completion.max")) { + intTextField(1..1000).bindIntText(settings::maxCompletions).columns(6) + } + } + + collapsibleGroup(message("cloudformation.settings.cfnlint.group")) { + row { + checkBox(message("cloudformation.settings.cfnlint.enable")).bindSelected(settings::isCfnLintEnabled) + } + row { + checkBox(message("cloudformation.settings.cfnlint.lintOnChange")).bindSelected(settings::cfnLintLintOnChange) + } + row(message("cloudformation.settings.cfnlint.delayMs")) { + intTextField(0..60000).bindIntText(settings::cfnLintDelayMs).columns(6).comment(message("cloudformation.settings.cfnlint.delayMs.comment")) + } + row(message("cloudformation.settings.cfnlint.path")) { + textField().bindText(settings::cfnLintPath).columns(30).comment(message("cloudformation.settings.cfnlint.path.comment")) + } + row { + checkBox(message("cloudformation.settings.cfnlint.includeExperimental")).bindSelected(settings::cfnLintIncludeExperimental) + } + row(message("cloudformation.settings.cfnlint.ignoreChecks")) { + textField().bindText(settings::cfnLintIgnoreChecks).columns(30).comment(message("cloudformation.settings.cfnlint.ignoreChecks.comment")) + } + row(message("cloudformation.settings.cfnlint.includeChecks")) { + textField().bindText(settings::cfnLintIncludeChecks).columns(30).comment(message("cloudformation.settings.cfnlint.includeChecks.comment")) + } + row(message("cloudformation.settings.cfnlint.customRules")) { + textField().bindText(settings::cfnLintCustomRules).columns(30).comment(message("cloudformation.settings.cfnlint.customRules.comment")) + } + row(message("cloudformation.settings.cfnlint.appendRules")) { + textField().bindText(settings::cfnLintAppendRules).columns(30).comment(message("cloudformation.settings.cfnlint.appendRules.comment")) + } + row(message("cloudformation.settings.cfnlint.overrideSpec")) { + textField().bindText(settings::cfnLintOverrideSpec).columns(30).comment(message("cloudformation.settings.cfnlint.overrideSpec.comment")) + } + row(message("cloudformation.settings.cfnlint.registrySchemas")) { + textField().bindText(settings::cfnLintRegistrySchemas).columns(30).comment(message("cloudformation.settings.cfnlint.registrySchemas.comment")) + } + } + + collapsibleGroup(message("cloudformation.settings.cfnguard.group")) { + row { + checkBox(message("cloudformation.settings.cfnguard.enable")).bindSelected(settings::isCfnGuardEnabled) + } + row { + checkBox(message("cloudformation.settings.cfnguard.validateOnChange")).bindSelected(settings::cfnGuardValidateOnChange) + } + row(message("cloudformation.settings.cfnguard.enabledRulePacks")) { + val selected = settings.cfnGuardEnabledRulePacks.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + val checkBoxList = CheckBoxList().apply { + CFN_GUARD_RULE_PACKS.forEach { addItem(it, it, it in selected) } + } + + cell( + JBScrollPane(checkBoxList).apply { + preferredSize = JBUI.size(300, 150) + } + ).onApply { + settings.cfnGuardEnabledRulePacks = CFN_GUARD_RULE_PACKS.filter { checkBoxList.isItemSelected(it) }.joinToString(",") + }.onReset { + val current = settings.cfnGuardEnabledRulePacks.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + CFN_GUARD_RULE_PACKS.forEachIndexed { _, pack -> + checkBoxList.setItemSelected(pack, pack in current) + } + }.onIsModified { + val current = settings.cfnGuardEnabledRulePacks.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + val ui = CFN_GUARD_RULE_PACKS.filter { checkBoxList.isItemSelected(it) }.toSet() + current != ui + } + } + row(message("cloudformation.settings.cfnguard.rulesFile")) { + textFieldWithBrowseButton( + fileChooserDescriptor = FileChooserDescriptorFactory.singleFile() + ).bindText(settings::cfnGuardRulesFile).columns(30).comment(message("cloudformation.settings.cfnguard.rulesFile.comment")) + } + } + } + + override fun apply() { + super.apply() + settings.notifySettingsChanged() + } + + override fun getId(): String = "aws.cloudformation" +} + +private val CFN_GUARD_RULE_PACKS = listOf( + "ABS-CCIGv2-Material", + "ABS-CCIGv2-Standard", + "acsc-essential-8", + "acsc-ism", + "apra-cpg-234", + "bnm-rmit", + "cis-aws-benchmark-level-1", + "cis-aws-benchmark-level-2", + "cis-critical-security-controls-v8-ig1", + "cis-critical-security-controls-v8-ig2", + "cis-critical-security-controls-v8-ig3", + "cis-top-20", + "cisa-ce", + "cmmc-level-1", + "cmmc-level-2", + "cmmc-level-3", + "cmmc-level-4", + "cmmc-level-5", + "enisa-cybersecurity-guide-for-smes", + "ens-high", + "ens-low", + "ens-medium", + "FDA-21CFR-Part-11", + "FedRAMP-Low", + "FedRAMP-Moderate", + "ffiec", + "hipaa-security", + "K-ISMS", + "mas-notice-655", + "mas-trmg", + "nbc-trmg", + "ncsc-cafv3", + "ncsc", + "nerc", + "nist-1800-25", + "nist-800-171", + "nist-800-172", + "nist-800-181", + "nist-csf", + "nist-privacy-framework", + "NIST800-53Rev4", + "NIST800-53Rev5", + "nzism", + "PCI-DSS-3-2-1", + "rbi-bcsf-ucb", + "rbi-md-itf", + "us-nydfs", + "wa-Reliability-Pillar", + "wa-Security-Pillar", +) diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptStateTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptStateTest.kt new file mode 100644 index 00000000000..3b019a08455 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptStateTest.kt @@ -0,0 +1,69 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.testFramework.ApplicationRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class CfnLspIntroPromptStateTest { + + @Rule + @JvmField + val applicationRule = ApplicationRule() + + private lateinit var promptState: CfnLspIntroPromptState + + @Before + fun setUp() { + promptState = CfnLspIntroPromptState.getInstance() + promptState.loadState(CfnLspIntroPromptStateData(hasResponded = false)) + } + + @Test + fun `default state has not responded`() { + val state = CfnLspIntroPromptStateData() + assertThat(state.hasResponded).isFalse() + } + + @Test + fun `hasResponded persists through state`() { + promptState.setResponded() + assertThat(promptState.getState().hasResponded).isTrue() + } + + @Test + fun `loadState restores values`() { + promptState.loadState(CfnLspIntroPromptStateData(hasResponded = true)) + assertThat(promptState.hasResponded()).isTrue() + } + + @Test + fun `explore choice marks permanent response`() { + promptState.setResponded() + assertThat(promptState.hasResponded()).isTrue() + } + + @Test + fun `dont show again choice marks permanent response`() { + promptState.setResponded() + assertThat(promptState.hasResponded()).isTrue() + } + + @Test + fun `should prompt when no prior interaction`() { + assertThat(promptState.hasResponded()).isFalse() + + val shouldPrompt = !promptState.hasResponded() + assertThat(shouldPrompt).isTrue() + } + + @Test + fun `should not prompt when permanently responded`() { + promptState.setResponded() + assertThat(promptState.hasResponded()).isTrue() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManagerTest.kt new file mode 100644 index 00000000000..16a58b4222d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManagerTest.kt @@ -0,0 +1,79 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.documents + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class CfnDocumentManagerTest { + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var documentManager: CfnDocumentManager + + @Before + fun setUp() { + documentManager = CfnDocumentManager.getInstance(projectRule.project) + } + + @Test + fun `getValidTemplates filters by cfnType template`() { + val documents = listOf( + createDocument("template.yaml", "template", "yaml"), + createDocument("config.json", "other", "json"), + createDocument("stack.yaml", "template", "yaml") + ) + + documentManager.updateDocuments(documents) + + val validTemplates = documentManager.getValidTemplates() + assertThat(validTemplates).hasSize(2) + assertThat(validTemplates.map { it.fileName }).containsExactly("template.yaml", "stack.yaml") + } + + @Test + fun `getValidTemplates returns empty list when no templates`() { + val documents = listOf( + createDocument("config.json", "other", "json"), + createDocument("readme.md", "other", "md") + ) + + documentManager.updateDocuments(documents) + + val validTemplates = documentManager.getValidTemplates() + assertThat(validTemplates).isEmpty() + } + + @Test + fun `updateDocuments replaces existing documents`() { + val initialDocs = listOf(createDocument("old.yaml", "template", "yaml")) + documentManager.updateDocuments(initialDocs) + assertThat(documentManager.getValidTemplates()).hasSize(1) + + val newDocs = listOf( + createDocument("new1.yaml", "template", "yaml"), + createDocument("new2.yaml", "template", "yaml") + ) + documentManager.updateDocuments(newDocs) + + val validTemplates = documentManager.getValidTemplates() + assertThat(validTemplates).hasSize(2) + assertThat(validTemplates.map { it.fileName }).containsExactly("new1.yaml", "new2.yaml") + } + + private fun createDocument(fileName: String, cfnType: String, languageId: String) = DocumentMetadata( + uri = "file:///path/to/$fileName", + fileName = fileName, + ext = fileName.substringAfterLast('.'), + type = "document", + cfnType = cfnType, + languageId = languageId, + version = 1, + lineCount = 10 + ) +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNodeTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNodeTest.kt new file mode 100644 index 00000000000..cc7cb24dbe7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNodeTest.kt @@ -0,0 +1,100 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager + +class ResourcesNodeTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `shows add resource type node when no types selected`() { + val mockResourceTypesManager = mock() + val mockResourceLoader = mock() + + whenever(mockResourceTypesManager.getSelectedResourceTypes()).thenReturn(emptySet()) + + val node = ResourcesNode(projectRule.project, mockResourceTypesManager, mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(1) // Only AddResourceTypeNode + assertThat(children.filterIsInstance()).hasSize(1) + assertThat(children.filterIsInstance()).hasSize(0) + } + + @Test + fun `shows resource type nodes for selected types`() { + val mockResourceTypesManager = mock() + val mockResourceLoader = mock() + + whenever(mockResourceTypesManager.getSelectedResourceTypes()).thenReturn( + setOf("AWS::EC2::Instance", "AWS::S3::Bucket") + ) + + val node = ResourcesNode(projectRule.project, mockResourceTypesManager, mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(2) // Only 2 ResourceTypeNodes (no AddResourceTypeNode when types are selected) + assertThat(children.filterIsInstance()).hasSize(2) + assertThat(children.filterIsInstance()).hasSize(0) // No AddResourceTypeNode when types are selected + } + + @Test + fun `resource type node shows no resources when empty`() { + val mockResourceLoader = mock() + + whenever(mockResourceLoader.isLoaded("AWS::EC2::Instance")).thenReturn(true) + whenever(mockResourceLoader.getResourceIdentifiers("AWS::EC2::Instance")).thenReturn(emptyList()) + + val node = ResourceTypeNode(projectRule.project, "AWS::EC2::Instance", mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(1) + assertThat(children.first()).isInstanceOf(NoResourcesNode::class.java) + } + + @Test + fun `resource type node shows resource nodes when loaded`() { + val mockResourceLoader = mock() + + whenever(mockResourceLoader.isLoaded("AWS::EC2::Instance")).thenReturn(true) + whenever(mockResourceLoader.getResourceIdentifiers("AWS::EC2::Instance")).thenReturn( + listOf("testResource1", "testResource2") + ) + whenever(mockResourceLoader.hasMore("AWS::EC2::Instance")).thenReturn(false) + + val node = ResourceTypeNode(projectRule.project, "AWS::EC2::Instance", mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(2) + assertThat(children.filterIsInstance()).hasSize(2) + } + + @Test + fun `resource type node shows load more when has pagination`() { + val mockResourceLoader = mock() + + whenever(mockResourceLoader.isLoaded("AWS::EC2::Instance")).thenReturn(true) + whenever(mockResourceLoader.getResourceIdentifiers("AWS::EC2::Instance")).thenReturn( + listOf("testResource") + ) + whenever(mockResourceLoader.hasMore("AWS::EC2::Instance")).thenReturn(true) + + val node = ResourceTypeNode(projectRule.project, "AWS::EC2::Instance", mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(2) // 1 ResourceNode + 1 LoadMoreResourcesNode + assertThat(children.filterIsInstance()).hasSize(1) + assertThat(children.filterIsInstance()).hasSize(1) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StacksNodeTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StacksNodeTest.kt new file mode 100644 index 00000000000..17df59bab2d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StacksNodeTest.kt @@ -0,0 +1,169 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +class StacksNodeTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockStacksManager: StacksManager + private lateinit var mockChangeSetsManager: ChangeSetsManager + private lateinit var stacksNode: StacksNode + + @Before + fun setUp() { + mockStacksManager = mock() + mockChangeSetsManager = mock() + stacksNode = StacksNode(projectRule.project, mockStacksManager, mockChangeSetsManager) + } + + @Test + fun `getChildren returns empty and triggers reload when not loaded`() { + whenever(mockStacksManager.isLoaded()).thenReturn(false) + + val children = stacksNode.children + + assertThat(children).isEmpty() + org.mockito.kotlin.verify(mockStacksManager).reload() + } + + @Test + fun `getChildren returns NoStacksNode when loaded but empty`() { + whenever(mockStacksManager.isLoaded()).thenReturn(true) + whenever(mockStacksManager.get()).thenReturn(emptyList()) + + val children = stacksNode.children + + assertThat(children).hasSize(1) + assertThat(children.first()).isInstanceOf(NoStacksNode::class.java) + } + + @Test + fun `getChildren returns stack nodes when loaded with stacks`() { + whenever(mockStacksManager.isLoaded()).thenReturn(true) + whenever(mockStacksManager.get()).thenReturn( + listOf( + StackSummary(stackName = "stack-1", stackStatus = "CREATE_COMPLETE"), + StackSummary(stackName = "stack-2", stackStatus = "UPDATE_COMPLETE") + ) + ) + whenever(mockStacksManager.hasMore()).thenReturn(false) + + val children = stacksNode.children + + assertThat(children).hasSize(2) + assertThat(children).allMatch { it is StackNode } + } + + @Test + fun `getChildren includes LoadMoreStacksNode when hasMore is true`() { + whenever(mockStacksManager.isLoaded()).thenReturn(true) + whenever(mockStacksManager.get()).thenReturn( + listOf( + StackSummary(stackName = "stack-1", stackStatus = "CREATE_COMPLETE") + ) + ) + whenever(mockStacksManager.hasMore()).thenReturn(true) + + val children = stacksNode.children + + assertThat(children).hasSize(2) + assertThat(children.last()).isInstanceOf(LoadMoreStacksNode::class.java) + } + + @Test + fun `isAlwaysShowPlus returns true`() { + assertThat(stacksNode.isAlwaysShowPlus).isTrue() + } +} + +class StackNodeTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockChangeSetsManager: ChangeSetsManager + + @Before + fun setUp() { + mockChangeSetsManager = mock() + } + + @Test + fun `getStackIcon returns success icon for CREATE_COMPLETE`() { + val icon = getIconForStatus("CREATE_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.InspectionsOK) + } + + @Test + fun `getStackIcon returns success icon for UPDATE_COMPLETE`() { + val icon = getIconForStatus("UPDATE_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.InspectionsOK) + } + + @Test + fun `getStackIcon returns error icon for CREATE_FAILED`() { + val icon = getIconForStatus("CREATE_FAILED") + assertThat(icon).isEqualTo(AllIcons.General.Error) + } + + @Test + fun `getStackIcon returns error icon for ROLLBACK_COMPLETE`() { + val icon = getIconForStatus("ROLLBACK_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.Error) + } + + @Test + fun `getStackIcon returns error icon for UPDATE_ROLLBACK_COMPLETE`() { + val icon = getIconForStatus("UPDATE_ROLLBACK_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.Error) + } + + @Test + fun `getStackIcon returns progress icon for CREATE_IN_PROGRESS`() { + val icon = getIconForStatus("CREATE_IN_PROGRESS") + assertThat(icon).isEqualTo(AllIcons.Process.Step_1) + } + + @Test + fun `getStackIcon returns progress icon for UPDATE_IN_PROGRESS`() { + val icon = getIconForStatus("UPDATE_IN_PROGRESS") + assertThat(icon).isEqualTo(AllIcons.Process.Step_1) + } + + @Test + fun `getStackIcon returns folder icon for null status`() { + val icon = getIconForStatus(null) + assertThat(icon).isEqualTo(AllIcons.Nodes.Folder) + } + + @Test + fun `getStackIcon returns folder icon for unknown status`() { + val icon = getIconForStatus("UNKNOWN_STATUS") + assertThat(icon).isEqualTo(AllIcons.Nodes.Folder) + } + + private fun getIconForStatus(status: String?) = when { + status == null -> AllIcons.Nodes.Folder + status.contains("COMPLETE") && !status.contains("ROLLBACK") -> AllIcons.General.InspectionsOK + status.contains("FAILED") || status.contains("ROLLBACK") -> AllIcons.General.Error + status.contains("PROGRESS") -> AllIcons.Process.Step_1 + else -> AllIcons.Nodes.Folder + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt new file mode 100644 index 00000000000..1eea49dd3ca --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt @@ -0,0 +1,239 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceResult +import java.util.concurrent.CompletableFuture + +class ResourceLoaderTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `initially has no cached resources`() { + val loader = ResourceLoader(projectRule.project) + + assertThat(loader.getCachedResources("AWS::EC2::Instance")).isNull() + assertThat(loader.isLoaded("AWS::EC2::Instance")).isFalse() + assertThat(loader.getResourceIdentifiers("AWS::EC2::Instance")).isEmpty() + assertThat(loader.hasMore("AWS::EC2::Instance")).isFalse() + assertThat(loader.getLoadedResourceTypes()).isEmpty() + } + + @Test + fun `refreshResources sends request to LSP server`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + val mockResult = RefreshResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1"), null) + ) + ) + whenever(mockClientService.refreshResources(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + loader.clientServiceProvider = { mockClientService } + loader.refreshResources("AWS::EC2::Instance") + + val paramsCaptor = argumentCaptor() + verify(mockClientService).refreshResources(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.resources).hasSize(1) + assertThat(paramsCaptor.firstValue.resources.first().resourceType).isEqualTo("AWS::EC2::Instance") + } + + @Test + fun `searchResource adds found resource to cache`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + val mockResourceSummary = ResourceSummary("AWS::EC2::Instance", listOf("test-instance")) + val mockResult = SearchResourceResult(found = true, resource = mockResourceSummary) + whenever(mockClientService.searchResource(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + loader.clientServiceProvider = { mockClientService } + + assertThat(loader.getCachedResources("AWS::EC2::Instance")).isNull() + + val result = loader.searchResource("AWS::EC2::Instance", "test-instance") + assertThat(result.get()).isTrue() + + val paramsCaptor = argumentCaptor() + verify(mockClientService).searchResource(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.resourceType).isEqualTo("AWS::EC2::Instance") + assertThat(paramsCaptor.firstValue.identifier).isEqualTo("test-instance") + + // Resource should now be in cache + val cachedResources = loader.getResourceIdentifiers("AWS::EC2::Instance") + assertThat(cachedResources).contains("test-instance") + assertThat(loader.isLoaded("AWS::EC2::Instance")).isTrue() + } + + @Test + fun `searchResource returns false when resource not found`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + val mockResult = SearchResourceResult(found = false, resource = null) + whenever(mockClientService.searchResource(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + loader.clientServiceProvider = { mockClientService } + + val result = loader.searchResource("AWS::EC2::Instance", "test-instance") + + assertThat(result.get()).isFalse() + + val paramsCaptor = argumentCaptor() + verify(mockClientService).searchResource(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.resourceType).isEqualTo("AWS::EC2::Instance") + assertThat(paramsCaptor.firstValue.identifier).isEqualTo("test-instance") + } + + @Test + fun `searchResource handles exception gracefully`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + whenever(mockClientService.searchResource(any())).thenReturn(CompletableFuture.failedFuture(RuntimeException("Test exception"))) + loader.clientServiceProvider = { mockClientService } + + val result = loader.searchResource("AWS::EC2::Instance", "test-instance") + + assertThat(result.get()).isFalse() + } + + @Test + fun `addListener adds listener and notifies on clear`() { + val loader = ResourceLoader(projectRule.project) + var notificationReceived = false + var notifiedResourceType: String? = null + + val listener: ResourcesChangeListener = { resourceType, resources -> + notificationReceived = true + notifiedResourceType = resourceType + } + + loader.addListener(listener) + loader.clear("AWS::EC2::Instance") + + assertThat(notificationReceived).isTrue() + assertThat(notifiedResourceType).isEqualTo("AWS::EC2::Instance") + } + + @Test + fun `clear with null clears all resource types`() { + val loader = ResourceLoader(projectRule.project) + var ec2Cleared = false + var s3Cleared = false + + loader.addListener { resourceType, resources -> + when (resourceType) { + "AWS::EC2::Instance" -> ec2Cleared = resources.isEmpty() + "AWS::S3::Bucket" -> s3Cleared = resources.isEmpty() + } + } + + loader.clear("AWS::EC2::Instance") // Add some state + loader.clear("AWS::S3::Bucket") // Add some state + + loader.clear(null) + + assertThat(ec2Cleared).isTrue() + assertThat(s3Cleared).isTrue() + } + + @Test + fun `dispose clears listeners`() { + val loader = ResourceLoader(projectRule.project) + var notificationReceived = false + val listener: ResourcesChangeListener = { _, _ -> notificationReceived = true } + + loader.addListener(listener) + loader.dispose() + loader.clear("AWS::EC2::Instance") + + // Should not receive notification after dispose + assertThat(notificationReceived).isFalse() + } + + @Test + fun `loadMoreResources does nothing when no nextToken`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + loader.clientServiceProvider = { mockClientService } + + // First load some resources without nextToken + val mockResult = RefreshResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1"), null) + ) + ) + whenever(mockClientService.refreshResources(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + loader.refreshResources("AWS::EC2::Instance") + + // Reset mock to verify no additional calls + org.mockito.kotlin.reset(mockClientService) + + loader.loadMoreResources("AWS::EC2::Instance") + + // Should not make any LSP calls since no nextToken + verify(mockClientService, never()).listResources(any()) + } + + @Test + fun `loadMoreResources loads additional resources when nextToken exists`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + loader.clientServiceProvider = { mockClientService } + + // First load with nextToken + val initialResult = RefreshResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1"), "token123") + ) + ) + whenever(mockClientService.refreshResources(any())).thenReturn(CompletableFuture.completedFuture(initialResult)) + + loader.refreshResources("AWS::EC2::Instance") + + // Mock loadMore response - LSP server returns cumulative results + val loadMoreResult = ListResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1", "test-instance-2"), null) + ) + ) + whenever(mockClientService.listResources(any())).thenReturn(CompletableFuture.completedFuture(loadMoreResult)) + + loader.loadMoreResources("AWS::EC2::Instance") + + // Verify listResources was called with nextToken + val paramsCaptor = argumentCaptor() + verify(mockClientService).listResources(paramsCaptor.capture()) + assertThat(paramsCaptor.firstValue.resources?.first()?.nextToken).isEqualTo("token123") + + // Verify resources use server's cumulative results + val allResources = loader.getResourceIdentifiers("AWS::EC2::Instance") + assertThat(allResources).containsExactly("test-instance-1", "test-instance-2") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateServiceTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateServiceTest.kt new file mode 100644 index 00000000000..fc74b1704f1 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateServiceTest.kt @@ -0,0 +1,116 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStatePurpose +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateResult +import java.util.concurrent.CompletableFuture + +class ResourceStateServiceTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `importResourceState calls LSP client with correct params`() { + val mockClientService = mock() + val stateService = ResourceStateService(projectRule.project) + stateService.clientServiceProvider = { mockClientService } + + val mockResult = ResourceStateResult( + successfulImports = mapOf("AWS::EC2::Instance" to listOf("test-ec2")), + failedImports = emptyMap(), + completionItem = null, + warning = null + ) + whenever(mockClientService.getResourceState(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + // Mock the editor to return valid values + val mockEditor = mock() + whenever(mockEditor.getActiveEditor()).thenReturn(mock()) + whenever(mockEditor.getActiveDocumentUri()).thenReturn("file:///test.yaml") + stateService.editor = mockEditor + + val resourceNode = mock() + whenever(resourceNode.resourceType).thenReturn("AWS::EC2::Instance") + whenever(resourceNode.resourceIdentifier).thenReturn("test-ec2") + + stateService.importResourceState(listOf(resourceNode)) + + val paramsCaptor = argumentCaptor() + verify(mockClientService).getResourceState(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.purpose).isEqualTo(ResourceStatePurpose.IMPORT.value) + assertThat(paramsCaptor.firstValue.resourceSelections).hasSize(1) + assertThat(paramsCaptor.firstValue.resourceSelections?.first()?.resourceType).isEqualTo("AWS::EC2::Instance") + assertThat(paramsCaptor.firstValue.resourceSelections?.first()?.resourceIdentifiers).containsExactly("test-ec2") + } + + @Test + fun `cloneResourceState calls LSP client with correct params`() { + val mockClientService = mock() + val stateService = ResourceStateService(projectRule.project) + stateService.clientServiceProvider = { mockClientService } + + val mockResult = ResourceStateResult( + successfulImports = mapOf("AWS::S3::Bucket" to listOf("test-bucket")), + failedImports = emptyMap(), + completionItem = null, + warning = null + ) + whenever(mockClientService.getResourceState(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + // Mock the editor to return valid values + val mockEditor = mock() + whenever(mockEditor.getActiveEditor()).thenReturn(mock()) + whenever(mockEditor.getActiveDocumentUri()).thenReturn("file:///test.yaml") + stateService.editor = mockEditor + + val resourceNode = mock() + whenever(resourceNode.resourceType).thenReturn("AWS::S3::Bucket") + whenever(resourceNode.resourceIdentifier).thenReturn("test-bucket") + + stateService.cloneResourceState(listOf(resourceNode)) + + val paramsCaptor = argumentCaptor() + verify(mockClientService).getResourceState(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.purpose).isEqualTo(ResourceStatePurpose.CLONE.value) + } + + @Test + fun `getStackManagementInfo calls LSP client`() { + val mockClientService = mock() + val stateService = ResourceStateService(projectRule.project) + stateService.clientServiceProvider = { mockClientService } + + val mockResult = ResourceStackManagementResult( + physicalResourceId = "test-ec2", + managedByStack = true, + stackName = "test-stack", + stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/12345" + ) + whenever(mockClientService.getStackManagementInfo(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + val resourceNode = mock() + whenever(resourceNode.resourceIdentifier).thenReturn("test-ec2") + + stateService.getStackManagementInfo(resourceNode) + + verify(mockClientService).getStackManagementInfo("test-ec2") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManagerTest.kt new file mode 100644 index 00000000000..5a0a36f9bfe --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManagerTest.kt @@ -0,0 +1,217 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTypesResult +import java.util.concurrent.CompletableFuture + +class ResourceTypesManagerTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `initially has no selected resource types`() { + val manager = ResourceTypesManager(projectRule.project) + + assertThat(manager.getSelectedResourceTypes()).isEmpty() + assertThat(manager.areTypesLoaded()).isFalse() + assertThat(manager.getAvailableResourceTypes()).isEmpty() + } + + @Test + fun `can add resource types`() { + val manager = ResourceTypesManager(projectRule.project) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + + manager.addResourceType("AWS::S3::Bucket") + assertThat(manager.getSelectedResourceTypes()).containsExactlyInAnyOrder( + "AWS::EC2::Instance", + "AWS::S3::Bucket" + ) + } + + @Test + fun `loads available types from LSP server`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + val mockResult = ResourceTypesResult(listOf("AWS::EC2::Instance", "AWS::S3::Bucket")) + whenever(mockClientService.listResourceTypes()).thenReturn(CompletableFuture.completedFuture(mockResult)) + + manager.clientServiceProvider = { mockClientService } + + manager.loadAvailableTypes() + + verify(mockClientService).listResourceTypes() + assertThat(manager.getAvailableResourceTypes()).containsExactlyInAnyOrder("AWS::EC2::Instance", "AWS::S3::Bucket") + assertThat(manager.areTypesLoaded()).isTrue() + } + + @Test + fun `removeResourceType sends request to LSP server`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.completedFuture(null)) + + manager.clientServiceProvider = { mockClientService } + + manager.addResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + + manager.removeResourceType("AWS::EC2::Instance") + + verify(mockClientService).removeResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).isEmpty() + } + + @Test + fun `adding duplicate resource type is ignored`() { + val manager = ResourceTypesManager(projectRule.project) + + manager.addResourceType("AWS::EC2::Instance") + manager.addResourceType("AWS::EC2::Instance") + + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + } + + @Test + fun `removeResourceType removes from state immediately even when LSP server fails`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.failedFuture(RuntimeException("Test exception"))) + + manager.clientServiceProvider = { mockClientService } + + manager.addResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + + manager.removeResourceType("AWS::EC2::Instance") + + verify(mockClientService).removeResourceType("AWS::EC2::Instance") + // Should remove from state immediately for responsive UI, even if LSP call fails + assertThat(manager.getSelectedResourceTypes()).isEmpty() + } + + @Test + fun `removeResourceType does nothing for non-existent type`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + manager.clientServiceProvider = { mockClientService } + + manager.removeResourceType("AWS::EC2::Instance") + + verify(mockClientService, never()).removeResourceType(any()) + assertThat(manager.getSelectedResourceTypes()).isEmpty() + } + + @Test + fun `loadAvailableTypes handles null result gracefully`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.listResourceTypes()).thenReturn(CompletableFuture.completedFuture(null)) + + manager.clientServiceProvider = { mockClientService } + + manager.loadAvailableTypes() + + verify(mockClientService).listResourceTypes() + assertThat(manager.getAvailableResourceTypes()).isEmpty() + assertThat(manager.areTypesLoaded()).isFalse() + } + + @Test + fun `listeners are notified when resource types change`() { + val manager = ResourceTypesManager(projectRule.project) + var notificationCount = 0 + val listener: ResourceTypesChangeListener = { notificationCount++ } + + manager.addListener(listener) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(1) + + manager.addResourceType("AWS::S3::Bucket") + assertThat(notificationCount).isEqualTo(2) + + // Adding duplicate should not notify + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `listeners are notified when resource types are removed successfully`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.completedFuture(null)) + + manager.clientServiceProvider = { mockClientService } + + var notificationCount = 0 + val listener: ResourceTypesChangeListener = { notificationCount++ } + manager.addListener(listener) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(1) + + manager.removeResourceType("AWS::EC2::Instance") + + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `listeners are notified immediately when resource type removal is requested`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.failedFuture(RuntimeException("Test exception"))) + + manager.clientServiceProvider = { mockClientService } + + var notificationCount = 0 + val listener: ResourceTypesChangeListener = { notificationCount++ } + manager.addListener(listener) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(1) + + manager.removeResourceType("AWS::EC2::Instance") + + // Should notify immediately when removal is requested (responsive UI) + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `state persistence works correctly`() { + val manager = ResourceTypesManager(projectRule.project) + + manager.addResourceType("AWS::EC2::Instance") + manager.addResourceType("AWS::S3::Bucket") + + val state = manager.state + assertThat(state.selectedTypes).containsExactlyInAnyOrder("AWS::EC2::Instance", "AWS::S3::Bucket") + + // Simulate loading state + val newManager = ResourceTypesManager(projectRule.project) + newManager.loadState(state) + + assertThat(newManager.getSelectedResourceTypes()).containsExactlyInAnyOrder("AWS::EC2::Instance", "AWS::S3::Bucket") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusServiceTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusServiceTest.kt new file mode 100644 index 00000000000..e7a86c7b884 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusServiceTest.kt @@ -0,0 +1,130 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase + +class CfnOperationStatusServiceTest { + + @Rule + @JvmField + val projectRule = ProjectRule() + + private val service by lazy { CfnOperationStatusService(projectRule.project) } + + @Test + fun `status text is empty when no operations`() { + assertThat(service.getStatusText()).isEmpty() + } + + @Test + fun `single active validation shows verb and stack name`() { + service.acquire("my-stack", OperationType.VALIDATION) + assertThat(service.getStatusText()).isEqualTo("Validating my-stack") + } + + @Test + fun `single active deployment shows verb and stack name`() { + service.acquire("my-stack", OperationType.DEPLOYMENT, "cs-1") + assertThat(service.getStatusText()).isEqualTo("Deploying my-stack") + } + + @Test + fun `single completed validation shows done label`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.update(StackActionPhase.VALIDATION_COMPLETE) + assertThat(service.getStatusText()).isEqualTo("Validated my-stack") + } + + @Test + fun `single failed validation shows failed label`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.update(StackActionPhase.VALIDATION_FAILED) + assertThat(service.getStatusText()).isEqualTo("Validation Failed: my-stack") + } + + @Test + fun `single completed deployment shows done label`() { + val handle = service.acquire("my-stack", OperationType.DEPLOYMENT, "cs-1") + handle.update(StackActionPhase.DEPLOYMENT_COMPLETE) + assertThat(service.getStatusText()).isEqualTo("Deployed my-stack") + } + + @Test + fun `single failed deployment shows failed label`() { + val handle = service.acquire("my-stack", OperationType.DEPLOYMENT, "cs-1") + handle.update(StackActionPhase.DEPLOYMENT_FAILED) + assertThat(service.getStatusText()).isEqualTo("Deployment Failed: my-stack") + } + + @Test + fun `multiple operations shows count`() { + service.acquire("stack-1", OperationType.VALIDATION) + service.acquire("stack-2", OperationType.DEPLOYMENT) + assertThat(service.getStatusText()).isEqualTo("AWS CloudFormation (2)") + } + + @Test + fun `released operation excluded from status text`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.release() + assertThat(service.getStatusText()).isEmpty() + } + + @Test + fun `released operation excluded from active operations`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.release() + assertThat(service.getActiveOperations()).isEmpty() + } + + @Test + fun `released operation still in all operations`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.release() + assertThat(service.getAllOperations()).hasSize(1) + } + + @Test + fun `ref counting tracks multiple acquires and releases`() { + val h1 = service.acquire("stack-1", OperationType.VALIDATION) + val h2 = service.acquire("stack-2", OperationType.DEPLOYMENT) + assertThat(service.getActiveOperations()).hasSize(2) + + h1.release() + assertThat(service.getActiveOperations()).hasSize(1) + assertThat(service.getStatusText()).isEqualTo("Deploying stack-2") + + h2.release() + assertThat(service.getActiveOperations()).isEmpty() + } + + @Test + fun `update changes phase of correct operation`() { + val h1 = service.acquire("stack-1", OperationType.VALIDATION) + val h2 = service.acquire("stack-2", OperationType.DEPLOYMENT) + + h1.update(StackActionPhase.VALIDATION_COMPLETE) + + val ops = service.getActiveOperations() + val stack1 = ops.first { it.stackName == "stack-1" } + val stack2 = ops.first { it.stackName == "stack-2" } + assertThat(stack1.phase).isEqualTo(StackActionPhase.VALIDATION_COMPLETE) + assertThat(stack2.phase).isEqualTo(StackActionPhase.DEPLOYMENT_IN_PROGRESS) + } + + @Test + fun `concurrent acquires produce unique operations`() { + val handles = (1..10).map { service.acquire("stack-$it", OperationType.VALIDATION) } + assertThat(service.getActiveOperations()).hasSize(10) + assertThat(service.getStatusText()).isEqualTo("AWS CloudFormation (10)") + + handles.forEach { it.release() } + assertThat(service.getActiveOperations()).isEmpty() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflowTest.kt new file mode 100644 index 00000000000..7eadd237c33 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflowTest.kt @@ -0,0 +1,84 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeletionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import java.util.concurrent.CompletableFuture + +class ChangeSetDeletionWorkflowTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var workflow: ChangeSetDeletionWorkflow + + @Before + fun setUp() { + mockClientService = mock() + workflow = ChangeSetDeletionWorkflow(projectRule.project, mockClientService) + } + + @Test + fun `returns Failed when deleteChangeSet returns null`() { + whenever(mockClientService.deleteChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + + val result = workflow.delete("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + } + + @Test + fun `returns Success when deletion completes successfully`() { + whenever(mockClientService.deleteChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getChangeSetDeletionStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DELETION_COMPLETE, StackActionState.SUCCESSFUL) + ) + ) + + val result = workflow.delete("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Success::class.java) + } + + @Test + fun `returns Failed when deletion fails`() { + whenever(mockClientService.deleteChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getChangeSetDeletionStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DELETION_FAILED, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeChangeSetDeletionStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeDeletionStatusResult("id-1", StackActionPhase.DELETION_FAILED, StackActionState.FAILED, failureReason = "Access denied") + ) + ) + + val result = workflow.delete("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManagerTest.kt new file mode 100644 index 00000000000..11ee0a40318 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManagerTest.kt @@ -0,0 +1,64 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsResult +import java.util.concurrent.CompletableFuture + +class ChangeSetsManagerTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var changeSetsManager: ChangeSetsManager + + @Before + fun setUp() { + mockClientService = mock() + changeSetsManager = ChangeSetsManager(projectRule.project).apply { + clientServiceProvider = { mockClientService } + } + } + + @Test + fun `get returns empty list for unknown stack`() { + assertThat(changeSetsManager.get("unknown-stack")).isEmpty() + } + + @Test + fun `hasMore returns false for unknown stack`() { + assertThat(changeSetsManager.hasMore("unknown-stack")).isFalse() + } + + @Test + fun `fetchChangeSets calls listChangeSets on client service`() { + whenever(mockClientService.listChangeSets(any())).thenReturn( + CompletableFuture.completedFuture(ListChangeSetsResult(emptyList(), null)) + ) + + changeSetsManager.fetchChangeSets("my-stack") + + verify(mockClientService).listChangeSets(any()) + } + + @Test + fun `loadMoreChangeSets does nothing when no cached data`() { + changeSetsManager.loadMoreChangeSets("unknown-stack") + + verify(mockClientService, never()).listChangeSets(any()) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflowTest.kt new file mode 100644 index 00000000000..65fe90c1cc0 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflowTest.kt @@ -0,0 +1,99 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeploymentStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewPanelTabber +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewTab +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import java.util.concurrent.CompletableFuture + +class DeploymentWorkflowTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var mockWindowManager: StackViewWindowManager + private lateinit var mockTabber: StackViewPanelTabber + private lateinit var workflow: DeploymentWorkflow + + @Before + fun setUp() { + mockClientService = mock() + mockWindowManager = mock() + mockTabber = mock() + workflow = DeploymentWorkflow(projectRule.project, mockClientService, mockWindowManager) + } + + @Test + fun `returns Failed when createDeployment returns null`() { + whenever(mockClientService.createDeployment(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + whenever(mockWindowManager.getOrOpenTabber("test-stack")).thenReturn(mockTabber) + + val result = workflow.deploy("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + verify(mockTabber).switchToTab(StackViewTab.EVENTS) + verify(mockTabber, never()).restartStatusPolling() + } + + @Test + fun `returns Success when deployment completes successfully`() { + whenever(mockClientService.createDeployment(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getDeploymentStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DEPLOYMENT_COMPLETE, StackActionState.SUCCESSFUL) + ) + ) + whenever(mockWindowManager.getOrOpenTabber("test-stack")).thenReturn(mockTabber) + + val result = workflow.deploy("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Success::class.java) + verify(mockTabber).switchToTab(StackViewTab.EVENTS) + verify(mockTabber).restartStatusPolling() + } + + @Test + fun `returns Failed when deployment fails`() { + whenever(mockClientService.createDeployment(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getDeploymentStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DEPLOYMENT_FAILED, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeDeploymentStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeDeploymentStatusResult("id-1", StackActionPhase.DEPLOYMENT_FAILED, StackActionState.FAILED, failureReason = "Rollback") + ) + ) + + val result = workflow.deploy("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManagerTest.kt new file mode 100644 index 00000000000..cebb95b0b22 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManagerTest.kt @@ -0,0 +1,98 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksResult +import java.util.concurrent.CompletableFuture + +class StacksManagerTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var stacksManager: StacksManager + + @Before + fun setUp() { + mockClientService = mock() + stacksManager = StacksManager(projectRule.project).apply { + clientServiceProvider = { mockClientService } + } + } + + @Test + fun `initial state is not loaded with empty stacks`() { + assertThat(stacksManager.isLoaded()).isFalse() + assertThat(stacksManager.get()).isEmpty() + assertThat(stacksManager.hasMore()).isFalse() + } + + @Test + fun `clear resets state and notifies listeners`() { + var notified = false + stacksManager.addListener { notified = true } + + stacksManager.clear() + + assertThat(stacksManager.isLoaded()).isFalse() + assertThat(stacksManager.get()).isEmpty() + assertThat(stacksManager.hasMore()).isFalse() + assertThat(notified).isTrue() + } + + @Test + fun `reload calls listStacks on client service`() { + whenever(mockClientService.listStacks(any())).thenReturn( + CompletableFuture.completedFuture(ListStacksResult(emptyList(), null)) + ) + + stacksManager.reload() + + verify(mockClientService).listStacks(any()) + } + + @Test + fun `loadMoreStacks does nothing when no nextToken`() { + stacksManager.loadMoreStacks() + + verify(mockClientService, never()).listStacks(any()) + } + + @Test + fun `listener is notified on clear`() { + val notifications = mutableListOf() + stacksManager.addListener { notifications.add(it.size) } + + stacksManager.clear() + + assertThat(notifications).containsExactly(0) + } + + @Test + fun `multiple listeners are all notified`() { + var listener1Called = false + var listener2Called = false + + stacksManager.addListener { listener1Called = true } + stacksManager.addListener { listener2Called = true } + + stacksManager.clear() + + assertThat(listener1Called).isTrue() + assertThat(listener2Called).isTrue() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflowTest.kt new file mode 100644 index 00000000000..6dea6f77f6f --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflowTest.kt @@ -0,0 +1,126 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import java.util.concurrent.CompletableFuture + +class ValidationWorkflowTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var workflow: ValidationWorkflow + + @Before + fun setUp() { + mockClientService = mock() + workflow = ValidationWorkflow(projectRule.project, mockClientService) + } + + @Test + fun `returns Failed when createValidation returns null`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Failed::class.java) + assertThat((result as ValidationResult.Failed).reason).isEqualTo("Failed to start validation") + } + + @Test + fun `returns Success when validation completes successfully`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.SUCCESSFUL, emptyList()) + ) + ) + whenever(mockClientService.describeValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeValidationStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.SUCCESSFUL) + ) + ) + whenever(mockClientService.describeChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Success::class.java) + val success = result as ValidationResult.Success + assertThat(success.changeSetName).isEqualTo("changeset-1") + } + + @Test + fun `returns Failed when validation completes with failure state`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeValidationStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.FAILED, failureReason = "Template error") + ) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Failed::class.java) + assertThat((result as ValidationResult.Failed).reason).isEqualTo("Template error") + } + + @Test + fun `returns Failed when phase is VALIDATION_FAILED`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.VALIDATION_FAILED, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeValidationStatusResult("id-1", StackActionPhase.VALIDATION_FAILED, StackActionState.FAILED, failureReason = "Syntax error") + ) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Failed::class.java) + assertThat((result as ValidationResult.Failed).reason).isEqualTo("Syntax error") + } + + private fun createParams() = CreateValidationParams( + id = "test-id", + uri = "file:///test.yaml", + stackName = "test-stack", + keepChangeSet = true + ) +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanelTest.kt new file mode 100644 index 00000000000..19654718d10 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanelTest.kt @@ -0,0 +1,408 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import com.intellij.ui.components.JBLabel +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import software.aws.toolkits.jetbrains.utils.notifyInfo +import java.util.concurrent.CompletableFuture +import javax.swing.JScrollPane +import javax.swing.JTable + +class StackEventsPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + mockkStatic("software.aws.toolkits.jetbrains.utils.NotificationUtilsKt") + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addPollingListener(any(), any()) } returns mockk(relaxed = true) + every { mockCfnClient.clearStackEvents(any()) } returns CompletableFuture.completedFuture(null) + every { notifyInfo(any(), any(), any()) } returns Unit + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + unmockkStatic("software.aws.toolkits.jetbrains.utils.NotificationUtilsKt") + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + } + + @Test + fun `loads events on initialization`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + verify { mockCfnClient.getStackEvents(any()) } + } finally { + panel.dispose() + } + } + + @Test + fun `displays events in table correctly`() { + val events = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event1", + logicalResourceId = "MyResource", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:00Z", + operationId = "op1" + ), + StackEvent( + stackId = testStackArn, + eventId = "event2", + logicalResourceId = "MyFunction", + resourceType = "AWS::Lambda::Function", + resourceStatus = "CREATE_IN_PROGRESS", + timestamp = "2025-01-01T12:01:00Z", + operationId = "op2" + ) + ) + val result = GetStackEventsResult(events, null, false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.component).isNotNull() + } finally { + panel.dispose() + } + } + + @Test + fun `pagination buttons work correctly`() { + val events = (1..100).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, "nextToken123", false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + // Verify button states and text + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Next") + assertThat(panel.prevButton.isEnabled).isFalse() // Should be disabled on first page + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 2") // 100 events = 2 pages + } finally { + panel.dispose() + } + } + + @Test + fun `shows load more button when more data needed from server`() { + val events = (1..50).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, "nextToken123", false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Load More") + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 1") + } finally { + panel.dispose() + } + } + + @Test + fun `buttons disabled during loading`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + assertThat(panel.nextButton.isEnabled).isFalse() + assertThat(panel.prevButton.isEnabled).isFalse() + } finally { + panel.dispose() + } + } + + @Test + fun `page navigation updates button states correctly`() { + val events = (1..100).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, null, false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + // Initial state: page 1 of 2 + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 2") + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Next") + + // Navigate to page 2 + panel.nextButton.doClick() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Page 2 state: last page + assertThat(panel.pageLabel.text).isEqualTo("Page 2 of 2") + assertThat(panel.prevButton.isEnabled).isTrue() + assertThat(panel.nextButton.isEnabled).isFalse() + } finally { + panel.dispose() + } + } + + @Test + fun `onStackPolled triggers refresh`() { + val initialEvents = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event1", + logicalResourceId = "Resource1", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:00Z", + operationId = "op1" + ) + ) + val refreshEvents = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event2", + logicalResourceId = "Resource2", + resourceType = "AWS::Lambda::Function", + resourceStatus = "CREATE_IN_PROGRESS", + timestamp = "2025-01-01T12:01:00Z", + operationId = "op2" + ) + ) + + val initialResult = CompletableFuture.completedFuture(GetStackEventsResult(initialEvents, null, false)) + val refreshResult = CompletableFuture.completedFuture(GetStackEventsResult(refreshEvents, null, false)) + + every { mockCfnClient.getStackEvents(match { it.refresh != true }) } returns initialResult + every { mockCfnClient.getStackEvents(match { it.refresh == true }) } returns refreshResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + panel.onStackPolled() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + verify { mockCfnClient.getStackEvents(match { it.refresh == true }) } + } finally { + panel.dispose() + } + } + + @Test + fun `handles gap detection with full reload`() { + val initialEvents = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event1", + logicalResourceId = "Resource1", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:00Z", + operationId = "op1" + ) + ) + val gapDetectedResult = GetStackEventsResult(emptyList(), null, true) + val reloadResult = GetStackEventsResult(initialEvents, null, false) + + val initialResult = CompletableFuture.completedFuture(GetStackEventsResult(initialEvents, null, false)) + val refreshResult = CompletableFuture.completedFuture(gapDetectedResult) + val fullReloadResult = CompletableFuture.completedFuture(reloadResult) + + every { mockCfnClient.getStackEvents(match { it.refresh != true && it.nextToken == null }) } returnsMany listOf(initialResult, fullReloadResult) + every { mockCfnClient.getStackEvents(match { it.refresh == true }) } returns refreshResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + panel.onStackPolled() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + verify(exactly = 3) { mockCfnClient.getStackEvents(any()) } + // Should show notification to user about gap detection + verify { notifyInfo("CloudFormation Events", "Event history reloaded due to high activity", projectRule.project) } + } finally { + panel.dispose() + } + } + + @Test + fun `clears events cache on dispose`() { + val futureResult = CompletableFuture.completedFuture(GetStackEventsResult(emptyList(), null, false)) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + panel.dispose() + + verify { mockCfnClient.clearStackEvents(any()) } + } + + @Test + fun `updates event count label correctly`() { + val events = (1..75).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, "nextToken123", false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + val eventCountLabelField = panel.javaClass.getDeclaredField("eventCountLabel").apply { isAccessible = true } + val eventCountLabel = eventCountLabelField.get(panel) as JBLabel + + assertThat(eventCountLabel.text).isEqualTo("75 events loaded") + } finally { + panel.dispose() + } + } + + @Test + fun `handles API error correctly`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + futureResult.completeExceptionally(RuntimeException("API error")) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.nextButton.isEnabled).isFalse() + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 1") + // Verify error message is displayed in table + val tableComponent = panel.component.components + .filterIsInstance() + .first().viewport.view as JTable + assertThat(tableComponent.getValueAt(0, 4)).asString().contains("Failed to load events:") + assertThat(tableComponent.getValueAt(0, 4)).asString().contains("API error") + } finally { + panel.dispose() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanelTest.kt new file mode 100644 index 00000000000..d958826453f --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanelTest.kt @@ -0,0 +1,257 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackOutput +import java.util.concurrent.CompletableFuture + +class StackOutputsPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addStatusListener(any(), any()) } returns mockk() + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + } + + @Test + fun `onStackUpdated triggers outputs reload and updates table correctly`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "my-test-stack") + + // Assert initial state + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.outputTable.rowCount).isEqualTo(1) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + + val testOutputs = listOf( + StackOutput( + outputKey = "WebsiteURL", + outputValue = "https://example.com", + description = "Website URL", + exportName = "MyStack-WebsiteURL" + ), + StackOutput( + outputKey = "DatabaseEndpoint", + outputValue = "db.example.com:5432", + description = "Database connection endpoint", + exportName = null + ) + ) + + val testStack = StackDetail( + stackName = "my-test-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = testOutputs + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("2 outputs") + assertThat(panel.consoleLink.isVisible).isTrue() + assertThat(panel.outputTable.rowCount).isEqualTo(2) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("WebsiteURL") + assertThat(panel.outputTable.getValueAt(0, 1)).isEqualTo("https://example.com") + assertThat(panel.outputTable.getValueAt(0, 2)).isEqualTo("Website URL") + assertThat(panel.outputTable.getValueAt(0, 3)).isEqualTo("MyStack-WebsiteURL") + assertThat(panel.outputTable.getValueAt(1, 3)).isEqualTo("") + } + + @Test + fun `onStackUpdated with empty outputs shows no outputs found`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "empty-stack") + + val testStack = StackDetail( + stackName = "empty-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = emptyList() + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isTrue() + assertThat(panel.outputTable.rowCount).isEqualTo(1) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + assertThat(panel.outputTable.getValueAt(0, 1)).isEqualTo("") + } + + @Test + fun `onStackUpdated with single output uses singular form`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "single-output-stack") + + val testStack = StackDetail( + stackName = "single-output-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = listOf( + StackOutput( + outputKey = "SingleOutput", + outputValue = "single-value", + description = "Single output description", + exportName = null + ) + ) + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("1 output") + } + + @Test + fun `onStackUpdated with empty stack ID hides console link`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + val testStack = StackDetail( + stackName = "test-stack", + stackId = "", + stackStatus = "CREATE_COMPLETE", + outputs = listOf( + StackOutput( + outputKey = "TestOutput", + outputValue = "test-value", + description = null, + exportName = null + ) + ) + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.consoleLink.isVisible).isFalse() + } + + @Test + fun `onStackUpdated with error shows error message`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "error-stack") + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + + // Complete with error + futureResult.completeExceptionally(RuntimeException("Test error")) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.outputTable.getValueAt(0, 0)).asString().contains("Failed to load outputs for stack error-stack") + assertThat(panel.outputTable.getValueAt(0, 0)).asString().contains("Test error") + } + + @Test + fun `onStackUpdated with null result shows empty state`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "null-stack") + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + + futureResult.complete(null) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + } + + @Test + fun `onStackUpdated with null outputs shows empty state`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "null-outputs-stack") + + val testStack = StackDetail( + stackName = "null-outputs-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = null + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isTrue() // as long as an error isn't thrown we should show console link + assertThat(panel.outputTable.rowCount).isEqualTo(1) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt new file mode 100644 index 00000000000..6fba17e432e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt @@ -0,0 +1,159 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import java.util.concurrent.CompletableFuture + +class StackOverviewPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addStatusListener(any(), any()) } returns mockk() + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + } + + @Test + fun `renderStack updates all field values correctly`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "my-test-stack") + + val testStack = StackDetail( + stackName = "my-test-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = "Test stack description", + creationTime = "2024-01-15T10:30:45Z", + lastUpdatedTime = "2024-01-15T11:00:00Z", + stackStatusReason = "Stack creation completed successfully" + ) + + panel.renderStack(testStack) + + assertThat(panel.stackNameValue.text).isEqualTo("my-test-stack") + assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE") + assertThat(panel.stackIdValue.text).isEqualTo(testStackArn) + assertThat(panel.descriptionValue.text).isEqualTo("Test stack description") + assertThat(panel.consoleLink.isVisible).isTrue() + } + + @Test + fun `renderStack with empty stack ID hides console link`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + val testStack = StackDetail( + stackName = "test-stack", + stackId = "", + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = null, + lastUpdatedTime = null, + stackStatusReason = null + ) + + panel.renderStack(testStack) + + assertThat(panel.consoleLink.isVisible).isFalse() + } + + @Test + fun `renderStack with null optional fields handles gracefully`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "minimal-stack") + + val testStack = StackDetail( + stackName = "minimal-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = null, + lastUpdatedTime = null, + stackStatusReason = null + ) + + panel.renderStack(testStack) + + assertThat(panel.stackNameValue.text).isEqualTo("minimal-stack") + assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE") + } + + @Test + fun `renderStack formats dates correctly`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "date-test-stack") + + val testStack = StackDetail( + stackName = "date-test-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = "2024-01-15T10:30:45Z", + lastUpdatedTime = "2024-01-15T11:00:00Z", + stackStatusReason = null + ) + + panel.renderStack(testStack) + + assertThat(panel.createdValue.text).contains("15/1/2024") + assertThat(panel.lastUpdatedValue.text).contains("15/1/2024") + } + + @Test + fun `onStackStatusUpdated triggers stack reload`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "my-stack") + + // Create a future we can control + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + // Should trigger reload + panel.onStackStatusUpdated() + + // Complete the future synchronously + val mockStack = StackDetail( + stackName = "my-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = null, + lastUpdatedTime = null, + stackStatusReason = null + ) + futureResult.complete(DescribeStackResult(mockStack)) + + // Process EDT events to execute the invokeLater block from loadStackDetails + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.stackNameValue.text).isEqualTo("my-stack") + assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt new file mode 100644 index 00000000000..23b055749ef --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt @@ -0,0 +1,141 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JPanel + +class StackPanelLayoutBuilderTest { + + @Test + fun `createTitleLabel creates label with correct styling`() { + val label = StackPanelLayoutBuilder.createTitleLabel("Test Label") + + assertThat(label.text).isEqualTo("Test Label") + assertThat(label.foreground).isEqualTo(UIUtil.getContextHelpForeground()) + assertThat(label.font.isBold).isTrue() + } + + @Test + fun `createFormPanel creates panel with GridBagLayout and default padding`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + + assertThat(panel.layout).isInstanceOf(GridBagLayout::class.java) + assertThat(panel.border).isNotNull() + } + + @Test + fun `createFormPanel creates panel with custom padding`() { + val panel = StackPanelLayoutBuilder.createFormPanel(30) + + assertThat(panel.layout).isInstanceOf(GridBagLayout::class.java) + assertThat(panel.border).isNotNull() + } + + @Test + fun `addLabeledField adds label and component to panel`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints().apply { + anchor = GridBagConstraints.NORTHWEST + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + } + val testComponent = JBLabel("Test Component") + + val nextRow = StackPanelLayoutBuilder.addLabeledField( + panel, + gbc, + 0, + "Test Field", + testComponent + ) + + assertThat(nextRow).isEqualTo(2) + assertThat(panel.componentCount).isEqualTo(2) // Label + component + + // Verify first component is the title label + val titleLabel = panel.getComponent(0) as JBLabel + assertThat(titleLabel.text).isEqualTo("Test Field") + assertThat(titleLabel.font.isBold).isTrue() + + // Verify second component is our test component + assertThat(panel.getComponent(1)).isEqualTo(testComponent) + } + + @Test + fun `addLabeledField with fillNone modifies constraints correctly`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints().apply { + anchor = GridBagConstraints.NORTHWEST + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + } + val testComponent = JBLabel("Test Component") + + StackPanelLayoutBuilder.addLabeledField( + panel, + gbc, + 0, + "Test Field", + testComponent, + fillNone = true + ) + + // Constraints should be reset after the method + assertThat(gbc.fill).isEqualTo(GridBagConstraints.HORIZONTAL) + assertThat(gbc.anchor).isEqualTo(GridBagConstraints.NORTHWEST) + } + + @Test + fun `addLabeledField with isLast uses different insets`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints() + val testComponent = JBLabel("Test Component") + + val nextRow = StackPanelLayoutBuilder.addLabeledField( + panel, + gbc, + 0, + "Test Field", + testComponent, + isLast = true + ) + + assertThat(nextRow).isEqualTo(2) + assertThat(panel.componentCount).isEqualTo(2) + } + + @Test + fun `addFiller adds empty panel with correct constraints`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints() + + StackPanelLayoutBuilder.addFiller(panel, gbc, 2) + + assertThat(panel.componentCount).isEqualTo(1) + assertThat(panel.getComponent(0)).isInstanceOf(JPanel::class.java) + assertThat(gbc.gridy).isEqualTo(4) // row + 2 + assertThat(gbc.weighty).isEqualTo(1.0) + assertThat(gbc.fill).isEqualTo(GridBagConstraints.BOTH) + } + + @Test + fun `multiple addLabeledField calls increment rows correctly`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints() + + var row = 0 + row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 1", JBLabel("Value 1")) + row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 2", JBLabel("Value 2")) + row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 3", JBLabel("Value 3")) + + assertThat(row).isEqualTo(6) // 3 fields * 2 rows each + assertThat(panel.componentCount).isEqualTo(6) // 3 labels + 3 components + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanelTest.kt new file mode 100644 index 00000000000..36dc6c5e446 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanelTest.kt @@ -0,0 +1,286 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStackResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackResourceSummary +import java.util.concurrent.CompletableFuture +import javax.swing.JScrollPane +import javax.swing.JTable + +class StackResourcesPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addPollingListener(any(), any()) } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + // Ensure all EDT events are processed to prevent test interference + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + } + + @Test + fun `loads resources on initialization`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + verify { mockCfnClient.getStackResources(any()) } + } finally { + panel.dispose() + } + } + + @Test + fun `displays resources in table correctly`() { + val resources = listOf( + StackResourceSummary("LogicalId1", "PhysicalId1", "AWS::S3::Bucket", "CREATE_COMPLETE", null), + StackResourceSummary("LogicalId2", null, "AWS::Lambda::Function", "CREATE_IN_PROGRESS", null) + ) + val result = ListStackResourcesResult(resources, null) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.component).isNotNull() + assertThat(panel.consoleLink.isVisible).isTrue() + assertThat(panel.resourceCountLabel.text).isEqualTo("2 resources") + // Resources should be loaded and displayed in the table + } finally { + panel.dispose() + } + } + + @Test + fun `pagination buttons work correctly`() { + val resources = (1..100).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + } + val result = ListStackResourcesResult(resources, "nextToken123") + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Next") // First page shows "Next", not "Load More" + assertThat(panel.resourceCountLabel.text).isEqualTo("100 resources loaded") + } finally { + panel.dispose() + } + } + + @Test + fun `shows load more button when more data needed from server`() { + val resources = (1..50).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + } + val result = ListStackResourcesResult(resources, "nextToken123") + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Load More") // Exactly 50 resources + nextToken = "Load More" + assertThat(panel.resourceCountLabel.text).isEqualTo("50 resources loaded") + } finally { + panel.dispose() + } + } + + @Test + fun `prevents rapid fire clicks with isLoading flag`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + // Simulate rapid clicks - should only make one call due to isLoading flag + repeat(5) { + val loadResourcesMethod = panel.javaClass.getDeclaredMethod("loadResources").apply { + isAccessible = true + } + loadResourcesMethod.invoke(panel) + } + + try { + verify(exactly = 1) { mockCfnClient.getStackResources(any()) } + } finally { + panel.dispose() + } + } + + @Test + fun `onStackPolled resets to page 1 and reloads data`() { + val initialResources = (1..100).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + } + val refreshedResources = (1..75).map { + StackResourceSummary("NewId$it", "NewPhysical$it", "AWS::Lambda::Function", "UPDATE_COMPLETE", null) + } + + val initialResult = CompletableFuture.completedFuture(ListStackResourcesResult(initialResources, null)) + val refreshResult = CompletableFuture.completedFuture(ListStackResourcesResult(refreshedResources, null)) + + every { mockCfnClient.getStackResources(any()) } returnsMany listOf(initialResult, refreshResult) + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Navigate to page 2 first + val loadNextPageMethod = panel.javaClass.getDeclaredMethod("loadNextPage").apply { + isAccessible = true + } + loadNextPageMethod.invoke(panel) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Verify we're on page 2 + assertThat(panel.pageLabel.text).isEqualTo("Page 2") + + // Simulate coordinator calling onStackPolled + panel.onStackPolled() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Verify polling refresh made new LSP call and reset to page 1 + verify(atLeast = 2) { mockCfnClient.getStackResources(any()) } + assertThat(panel.pageLabel.text).isEqualTo("Page 1") + } finally { + panel.dispose() + } + } + + @Test + fun `makes additional LSP calls when navigating beyond cached data`() { + val firstCall = CompletableFuture.completedFuture( + ListStackResourcesResult( + (1..50).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + }, + "token1" + ) + ) + val secondCall = CompletableFuture.completedFuture( + ListStackResourcesResult( + (51..75).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + }, + null + ) + ) + + every { mockCfnClient.getStackResources(match { it.nextToken == null }) } returns firstCall + every { mockCfnClient.getStackResources(match { it.nextToken == "token1" }) } returns secondCall + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Simulate navigation that triggers server call + val loadNextPageMethod = panel.javaClass.getDeclaredMethod("loadNextPage").apply { + isAccessible = true + } + loadNextPageMethod.invoke(panel) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + verify { mockCfnClient.getStackResources(match { it.nextToken == "token1" }) } + } finally { + panel.dispose() + } + } + + @Test + fun `handles API error correctly`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + futureResult.completeExceptionally(RuntimeException("API error")) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.nextButton.isEnabled).isFalse() + assertThat(panel.pageLabel.text).isEqualTo("Page 1") + assertThat(panel.resourceCountLabel.text).isEqualTo("0 resources") + // Verify error message is displayed in table + val tableModel = panel.component.components + .filterIsInstance() + .first().viewport.view as JTable + assertThat(tableModel.getValueAt(0, 0)).asString().contains("Failed to load resources:") + assertThat(tableModel.getValueAt(0, 0)).asString().contains("API error") + } finally { + panel.dispose() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt new file mode 100644 index 00000000000..9331089b657 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt @@ -0,0 +1,237 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class StackViewCoordinatorTest { + + private lateinit var coordinator: StackViewCoordinator + private val testStackArn1 = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack-1/12345" + private val testStackArn2 = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack-2/67890" + + @BeforeEach + fun setUp() { + coordinator = StackViewCoordinator() + } + + @Test + fun `setStack updates state and notifies listeners for specific stack`() { + var notificationCount = 0 + + val listener = object : StackStatusListener { + override fun onStackStatusUpdated() { + notificationCount++ + } + } + + coordinator.addStatusListener(testStackArn1, listener) + coordinator.setStack(testStackArn1, "test-stack-1") + + assertThat(notificationCount).isEqualTo(1) + + val state = coordinator.getStackState(testStackArn1) + assertThat(state?.stackName).isEqualTo("test-stack-1") + assertThat(state?.stackArn).isEqualTo(testStackArn1) + } + + @Test + fun `updateStackStatus only notifies listeners for specific stack`() { + var stack1Updates = 0 + var stack2Updates = 0 + + val listener1 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack1Updates++ + } + } + + val listener2 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack2Updates++ + } + } + + coordinator.addStatusListener(testStackArn1, listener1) + coordinator.addStatusListener(testStackArn2, listener2) + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + + // Reset counters after initial setStack calls + stack1Updates = 0 + stack2Updates = 0 + + // Update stack 1 status + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(stack1Updates).isEqualTo(1) + assertThat(stack2Updates).isEqualTo(0) + + // Update stack 2 status + coordinator.updateStackStatus(testStackArn2, "UPDATE_COMPLETE") + assertThat(stack1Updates).isEqualTo(1) + assertThat(stack2Updates).isEqualTo(1) + + // Same status should not notify + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(stack1Updates).isEqualTo(1) + } + + @Test + fun `multiple stacks can be managed independently`() { + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + + val state1 = coordinator.getStackState(testStackArn1) + val state2 = coordinator.getStackState(testStackArn2) + + assertThat(state1?.stackName).isEqualTo("stack-1") + assertThat(state2?.stackName).isEqualTo("stack-2") + + coordinator.updateStackStatus(testStackArn1, "CREATE_COMPLETE") + coordinator.updateStackStatus(testStackArn2, "UPDATE_IN_PROGRESS") + + val updatedState1 = coordinator.getStackState(testStackArn1) + val updatedState2 = coordinator.getStackState(testStackArn2) + + assertThat(updatedState1?.status).isEqualTo("CREATE_COMPLETE") + assertThat(updatedState2?.status).isEqualTo("UPDATE_IN_PROGRESS") + } + + @Test + fun `listeners only receive notifications for their registered stack`() { + val stack1Updates = mutableListOf() + val stack2Updates = mutableListOf() + + val listener1 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack1Updates.add("updated") + } + } + + val listener2 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack2Updates.add("updated") + } + } + + coordinator.addStatusListener(testStackArn1, listener1) + coordinator.addStatusListener(testStackArn2, listener2) + + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + coordinator.updateStackStatus(testStackArn1, "COMPLETE") + coordinator.updateStackStatus(testStackArn2, "FAILED") + + // Each listener should receive 2 notifications (setStack + updateStackStatus) + assertThat(stack1Updates).hasSize(2) + assertThat(stack2Updates).hasSize(2) + } + + @Test + fun `new listeners receive immediate notification of current state`() { + coordinator.setStack(testStackArn1, "existing-stack") + coordinator.updateStackStatus(testStackArn1, "CREATE_COMPLETE") + + var notificationCount = 0 + + val listener = object : StackStatusListener { + override fun onStackStatusUpdated() { + notificationCount++ + } + } + + // Listener should immediately receive current state + coordinator.addStatusListener(testStackArn1, listener) + + assertThat(notificationCount).isEqualTo(1) + } + + @Test + fun `removeStack cleans up state and listeners`() { + coordinator.setStack(testStackArn1, "stack-1") + coordinator.addStatusListener( + testStackArn1, + object : StackStatusListener { + override fun onStackStatusUpdated() {} + } + ) + + assertThat(coordinator.getStackState(testStackArn1)).isNotNull() + + coordinator.removeStack(testStackArn1) + + assertThat(coordinator.getStackState(testStackArn1)).isNull() + } + + @Test + fun `listener disposal removes listener for specific stack`() { + var notificationCount = 0 + + val listener = object : StackStatusListener { + override fun onStackStatusUpdated() { + notificationCount++ + } + } + + val disposable = coordinator.addStatusListener(testStackArn1, listener) + coordinator.setStack(testStackArn1, "test") + assertThat(notificationCount).isEqualTo(1) + + disposable.dispose() + coordinator.setStack(testStackArn1, "test-updated") + assertThat(notificationCount).isEqualTo(1) // Should not increment + } + + @Test + fun `updateStackStatus notifies both status and polling listeners`() { + var statusUpdates = 0 + var pollingUpdates = 0 + + val statusListener = object : StackStatusListener { + override fun onStackStatusUpdated() { + statusUpdates++ + } + } + + val pollingListener = object : StackPollingListener { + override fun onStackPolled() { + pollingUpdates++ + } + } + + coordinator.addStatusListener(testStackArn1, statusListener) + coordinator.addPollingListener(testStackArn1, pollingListener) + coordinator.setStack(testStackArn1, "test-stack") + + // Reset counters after initial setStack + statusUpdates = 0 + pollingUpdates = 0 + + // Status change should notify both + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(statusUpdates).isEqualTo(1) + assertThat(pollingUpdates).isEqualTo(1) + + // Same status should only notify polling listeners + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(statusUpdates).isEqualTo(1) // No change + assertThat(pollingUpdates).isEqualTo(2) // Always notified + } + + @Test + fun `dispose clears all stacks and listeners`() { + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + + assertThat(coordinator.getStackState(testStackArn1)).isNotNull() + assertThat(coordinator.getStackState(testStackArn2)).isNotNull() + + coordinator.dispose() + + assertThat(coordinator.getStackState(testStackArn1)).isNull() + assertThat(coordinator.getStackState(testStackArn2)).isNull() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt new file mode 100644 index 00000000000..eda4360946a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt @@ -0,0 +1,156 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.JBColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class UtilsTest { + + @Nested + inner class StackStatusUtilsTest { + + @Test + fun `getStatusColors returns green for COMPLETE statuses`() { + val testCases = listOf( + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + "DELETE_COMPLETE" + ) + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isEqualTo(JBColor.GREEN) + assertThat(fgColor).isEqualTo(JBColor.BLACK) + } + } + + @Test + fun `getStatusColors returns red for FAILED and ROLLBACK statuses`() { + val testCases = listOf( + "CREATE_FAILED", + "UPDATE_FAILED", + "ROLLBACK_COMPLETE", + "UPDATE_ROLLBACK_COMPLETE" + ) + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isEqualTo(JBColor.RED) + assertThat(fgColor).isEqualTo(JBColor.BLACK) + } + } + + @Test + fun `getStatusColors returns yellow for PROGRESS statuses`() { + val testCases = listOf( + "CREATE_IN_PROGRESS", + "UPDATE_IN_PROGRESS", + "DELETE_IN_PROGRESS" + ) + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isEqualTo(JBColor.YELLOW) + assertThat(fgColor).isEqualTo(JBColor.BLACK) + } + } + + @Test + fun `getStatusColors returns null for unknown statuses`() { + val testCases = listOf("UNKNOWN_STATUS", "", "RANDOM_TEXT") + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isNull() + assertThat(fgColor).isNull() + } + } + + @Test + fun `isInTransientState returns true for IN_PROGRESS statuses`() { + val testCases = listOf( + "CREATE_IN_PROGRESS", + "UPDATE_IN_PROGRESS", + "DELETE_IN_PROGRESS", + "UPDATE_CLEANUP_IN_PROGRESS" + ) + + testCases.forEach { status -> + assertThat(StackStatusUtils.isInTransientState(status)).isTrue() + } + } + + @Test + fun `isInTransientState returns false for terminal statuses`() { + val testCases = listOf( + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + "CREATE_FAILED", + "ROLLBACK_COMPLETE" + ) + + testCases.forEach { status -> + assertThat(StackStatusUtils.isInTransientState(status)).isFalse() + } + } + } + + @Nested + inner class StackDateFormatterTest { + + @Test + fun `formatDate formats valid ISO date string`() { + val isoDate = "2024-01-15T10:30:45Z" + val result = StackDateFormatter.formatDate(isoDate) + + // Should format to d/M/yyyy, h:mm:ss a pattern (timezone-dependent) + assertThat(result).isNotNull() + assertThat(checkNotNull(result)).contains("15/1/2024") + assertThat(result).satisfiesAnyOf( + { assertThat(it).contains("AM") }, + { assertThat(it).contains("PM") } + ) + assertThat(result).contains(":30:45") + } + + @Test + fun `formatDate formats date with milliseconds`() { + val isoDate = "2024-12-25T23:59:59.123Z" + val result = StackDateFormatter.formatDate(isoDate) + + assertThat(result).isNotNull() + assertThat(checkNotNull(result)).contains("25/12/2024") + assertThat(result).satisfiesAnyOf( + { assertThat(it).contains("AM") }, + { assertThat(it).contains("PM") } + ) + assertThat(result).contains(":59:59") + } + + @Test + fun `formatDate returns null for invalid date`() { + val invalidDate = "not-a-date" + val result = StackDateFormatter.formatDate(invalidDate) + + assertThat(result).isNull() + } + + @Test + fun `formatDate returns null for empty string`() { + val result = StackDateFormatter.formatDate("") + assertThat(result).isNull() + } + + @Test + fun `formatDate returns null for malformed ISO date`() { + val malformedDate = "2024-13-45T25:70:80Z" + val result = StackDateFormatter.formatDate(malformedDate) + + assertThat(result).isNull() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDriftTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDriftTest.kt new file mode 100644 index 00000000000..681aa6a8b3d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDriftTest.kt @@ -0,0 +1,182 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DriftInfo +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChange +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChangeDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTargetDefinition + +class ChangeSetDriftTest { + + @Test + fun `no drift returns original json unchanged`() { + val rc = ResourceChange(action = "Modify", logicalResourceId = "MyBucket") + val json = """{ "BucketName": "test" }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `deleted resource prepends comment`() { + val rc = ResourceChange(resourceDriftStatus = "DELETED") + val json = """{ "Key": "value" }""" + val result = annotateDriftInJson(rc, json) + assertThat(result).startsWith("// \u26A0\uFE0F Resource deleted out-of-band") + assertThat(result).endsWith(json) + } + + @Test + fun `drift annotation appended to correct property line`() { + val rc = ResourceChange( + action = "Modify", + logicalResourceId = "Fn", + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + name = "MemorySize", + path = "/Properties/MemorySize", + drift = DriftInfo(previousValue = "128", actualValue = "256"), + ) + ) + ) + ) + val json = """{ + "Properties": { + "MemorySize": 128, + "Runtime": "python3.12" + } +}""" + val result = annotateDriftInJson(rc, json) + val lines = result.lines() + val memoryLine = lines.first { it.contains("MemorySize") } + assertThat(memoryLine).contains("\u2190 \u26A0\uFE0F Drifted (Live AWS: 256)") + val runtimeLine = lines.first { it.contains("Runtime") } + assertThat(runtimeLine).doesNotContain("Drifted") + } + + @Test + fun `drift with LiveResourceDrift fallback`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + name = "Timeout", + path = "/Properties/Timeout", + liveResourceDrift = DriftInfo(previousValue = "30", actualValue = "60"), + ) + ) + ) + ) + val json = """{ + "Properties": { + "Timeout": 30 + } +}""" + val result = annotateDriftInJson(rc, json) + assertThat(result).contains("Drifted (Live AWS: 60)") + } + + @Test + fun `drift with null actualValue is ignored`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Properties/MemorySize", + drift = DriftInfo(previousValue = "128", actualValue = null), + ) + ) + ) + ) + val json = """{ "Properties": { "MemorySize": 128 } }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `drift with missing path is ignored`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + drift = DriftInfo(previousValue = "128", actualValue = "256"), + ) + ) + ) + ) + val json = """{ "MemorySize": 128 }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `path with numeric index skips array indices`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Tags/0/Value", + drift = DriftInfo(previousValue = "old", actualValue = "new"), + ) + ) + ) + ) + val json = """{ + "Tags": [ + { + "Key": "env", + "Value": "prod" + } + ] +}""" + val result = annotateDriftInJson(rc, json) + val valueLine = result.lines().first { it.contains("\"Value\"") } + assertThat(valueLine).contains("Drifted (Live AWS: new)") + } + + @Test + fun `unresolvable path leaves json unchanged`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/NonExistent/Property", + drift = DriftInfo(previousValue = "a", actualValue = "b"), + ) + ) + ) + ) + val json = """{ "Other": "value" }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `multiple drifted properties annotated independently`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Properties/MemorySize", + drift = DriftInfo(previousValue = "128", actualValue = "256"), + ) + ), + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Properties/Timeout", + drift = DriftInfo(previousValue = "30", actualValue = "60"), + ) + ), + ) + ) + val json = """{ + "Properties": { + "MemorySize": 128, + "Timeout": 30 + } +}""" + val result = annotateDriftInJson(rc, json) + assertThat(result).contains("Drifted (Live AWS: 256)") + assertThat(result).contains("Drifted (Live AWS: 60)") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt new file mode 100644 index 00000000000..1cb321c32f5 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt @@ -0,0 +1,62 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class UtilsTest { + + @Test + fun `generateUrl creates correct AWS console URL`() { + val stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack/" + + "12345678-1234-1234-1234-123456789012" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aus-east-1%3A123456789012%3Astack%2Fmy-stack%2F" + + "12345678-1234-1234-1234-123456789012" + assertThat(result).isEqualTo(expectedUrl) + } + + @Test + fun `generateUrl handles special characters in stack name`() { + val stackId = "arn:aws:cloudformation:us-west-2:123456789012:stack/" + + "my-stack-with-dashes_and_underscores/12345" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2F" + + "my-stack-with-dashes_and_underscores%2F12345" + assertThat(result).isEqualTo(expectedUrl) + } + + @Test + fun `generateUrl handles different regions`() { + val stackId = "arn:aws:cloudformation:eu-west-1:123456789012:stack/test-stack/abcdef" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aeu-west-1%3A123456789012%3Astack%2Ftest-stack%2Fabcdef" + assertThat(result).isEqualTo(expectedUrl) + } + + @Test + fun `generateUrl handles empty string`() { + val result = ConsoleUrlGenerator.generateUrl("") + assertThat(result).isEqualTo("https://console.aws.amazon.com/go/view?arn=") + } + + @Test + fun `generateUrl handles spaces and special characters`() { + val stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/" + + "stack with spaces & symbols/12345" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aus-east-1%3A123456789012%3Astack%2F" + + "stack+with+spaces+%26+symbols%2F12345" + assertThat(result).isEqualTo(expectedUrl) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt new file mode 100644 index 00000000000..0d861956e4a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt @@ -0,0 +1,47 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.system.CpuArch +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class LspUtilsTest { + + @Test + fun `getToolkitsCacheRoot returns platform-appropriate path`() { + val cacheRoot = getToolkitsCacheRoot() + + assertThat(cacheRoot.toString()).contains("aws") + assertThat(cacheRoot.toString()).contains("toolkits") + + when { + SystemInfo.isMac -> assertThat(cacheRoot.toString()).contains("Library/Caches") + SystemInfo.isWindows -> assertThat(cacheRoot.toString()).doesNotContain(".cache") + else -> assertThat(cacheRoot.toString()).contains(".cache") + } + } + + @Test + fun `getCurrentOS returns correct platform string`() { + val os = getCurrentOS() + + when { + SystemInfo.isWindows -> assertThat(os).isEqualTo("windows") + SystemInfo.isMac -> assertThat(os).isEqualTo("darwin") + else -> assertThat(os).isEqualTo("linux") + } + } + + @Test + fun `getCurrentArchitecture returns correct architecture string`() { + val arch = getCurrentArchitecture() + + when (CpuArch.CURRENT) { + CpuArch.ARM32, CpuArch.ARM64 -> assertThat(arch).isEqualTo("arm64") + else -> assertThat(arch).isEqualTo("x64") + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt new file mode 100644 index 00000000000..0c31d5c2a82 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt @@ -0,0 +1,103 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.nio.file.FileSystems +import java.nio.file.Path + +class NodeRuntimeResolverTest { + private val home = Path.of("/mock/home") + private val noEnv: (String) -> String? = { null } + private val fs = FileSystems.getDefault() + + @Test + fun `macOS well-known paths are valid`() { + val paths = buildWellKnownPaths(Platform.MAC, home) + assertThat(paths).isNotEmpty + assertThat(paths.map { it.toString() }).allSatisfy { assertThat(it).doesNotContain("*") } + } + + @Test + fun `linux well-known paths are valid`() { + val paths = buildWellKnownPaths(Platform.LINUX, home) + assertThat(paths).isNotEmpty + assertThat(paths.map { it.toString() }).allSatisfy { assertThat(it).doesNotContain("*") } + } + + @Test + fun `windows well-known paths are valid`() { + val paths = buildWellKnownPaths(Platform.WINDOWS, home) + assertThat(paths).isNotEmpty + assertThat(paths.map { it.toString() }).allSatisfy { assertThat(it).doesNotContain("*") } + } + + @Test + fun `macOS glob patterns are valid PathMatcher globs`() { + assertValidGlobs(buildGlobPatterns(Platform.MAC, home, noEnv)) + } + + @Test + fun `linux glob patterns are valid PathMatcher globs`() { + assertValidGlobs(buildGlobPatterns(Platform.LINUX, home, noEnv)) + } + + @Test + fun `windows glob patterns are valid PathMatcher globs with env vars`() { + val env: (String) -> String? = { + when (it) { + "APPDATA" -> "C:/Users/test/AppData/Roaming" + "LOCALAPPDATA" -> "C:/Users/test/AppData/Local" + else -> null + } + } + assertValidGlobs(buildGlobPatterns(Platform.WINDOWS, home, env)) + } + + @Test + fun `windows glob patterns handle missing env vars gracefully`() { + val patterns = buildGlobPatterns(Platform.WINDOWS, home, noEnv) + // With no env vars, nvm-windows/fnm/volta patterns requiring env vars are skipped + patterns.forEach { glob -> + fs.getPathMatcher("glob:$glob") + Path.of(glob.substringBefore("*")) + } + } + + @Test + fun `nvm glob respects NVM_DIR env var`() { + val env: (String) -> String? = { if (it == "NVM_DIR") "/custom/nvm" else null } + val patterns = buildGlobPatterns(Platform.LINUX, home, env) + assertThat(patterns).anyMatch { "custom" in it && "nvm" in it && "versions" in it } + } + + @Test + fun `glob pattern prefixes are valid paths on all platforms`() { + val windowsEnv: (String) -> String? = { + when (it) { + "APPDATA" -> "C:/Users/test/AppData/Roaming" + "LOCALAPPDATA" -> "C:/Users/test/AppData/Local" + else -> null + } + } + + for (platform in Platform.entries) { + val env = if (platform == Platform.WINDOWS) windowsEnv else noEnv + val patterns = buildGlobPatterns(platform, home, env) + patterns.forEach { glob -> + Path.of(glob.substringBefore("*")) + } + } + } + + private fun assertValidGlobs(patterns: List) { + assertThat(patterns).isNotEmpty + patterns.forEach { glob -> + assertThat(glob).contains("*") + fs.getPathMatcher("glob:$glob") + Path.of(glob.substringBefore("*")) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPromptStateTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPromptStateTest.kt new file mode 100644 index 00000000000..8c28aa64f7f --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPromptStateTest.kt @@ -0,0 +1,135 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.testFramework.ApplicationRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.settings.CfnLspSettings + +class CfnTelemetryPromptStateTest { + + @Rule + @JvmField + val applicationRule = ApplicationRule() + + private lateinit var promptState: CfnTelemetryPromptState + + @Before + fun setUp() { + promptState = CfnTelemetryPromptState.getInstance() + promptState.hasResponded = false + promptState.lastPromptDate = 0L + } + + @Test + fun `default state has not responded and no prompt date`() { + val state = CfnTelemetryPromptState.State() + assertThat(state.hasResponded).isFalse() + assertThat(state.lastPromptDate).isEqualTo(0L) + } + + @Test + fun `hasResponded persists through state`() { + promptState.hasResponded = true + assertThat(promptState.getState().hasResponded).isTrue() + } + + @Test + fun `lastPromptDate persists through state`() { + val now = System.currentTimeMillis() + promptState.lastPromptDate = now + assertThat(promptState.getState().lastPromptDate).isEqualTo(now) + } + + @Test + fun `loadState restores values`() { + val saved = CfnTelemetryPromptState.State(hasResponded = true, lastPromptDate = 12345L) + promptState.loadState(saved) + + assertThat(promptState.hasResponded).isTrue() + assertThat(promptState.lastPromptDate).isEqualTo(12345L) + } + + @Test + fun `allow choice enables telemetry and marks permanent`() { + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = true + promptState.lastPromptDate = System.currentTimeMillis() + settings.isTelemetryEnabled = true + + assertThat(promptState.hasResponded).isTrue() + assertThat(promptState.lastPromptDate).isGreaterThan(0L) + assertThat(settings.isTelemetryEnabled).isTrue() + + settings.isTelemetryEnabled = false + } + + @Test + fun `never choice disables telemetry and marks permanent`() { + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = true + promptState.lastPromptDate = System.currentTimeMillis() + settings.isTelemetryEnabled = false + + assertThat(promptState.hasResponded).isTrue() + assertThat(settings.isTelemetryEnabled).isFalse() + } + + @Test + fun `not now choice disables telemetry without marking permanent`() { + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = false + promptState.lastPromptDate = System.currentTimeMillis() + settings.isTelemetryEnabled = false + + assertThat(promptState.hasResponded).isFalse() + assertThat(promptState.lastPromptDate).isGreaterThan(0L) + assertThat(settings.isTelemetryEnabled).isFalse() + + settings.isTelemetryEnabled = false + } + + @Test + fun `should prompt when no prior interaction`() { + assertThat(promptState.hasResponded).isFalse() + assertThat(promptState.lastPromptDate).isEqualTo(0L) + + val shouldPrompt = !promptState.hasResponded && + (promptState.lastPromptDate == 0L || System.currentTimeMillis() - promptState.lastPromptDate >= 30L * 24 * 60 * 60 * 1000) + assertThat(shouldPrompt).isTrue() + } + + @Test + fun `should not prompt when permanently responded`() { + promptState.hasResponded = true + + assertThat(!promptState.hasResponded).isFalse() + } + + @Test + fun `should not prompt within 30 day window`() { + promptState.lastPromptDate = System.currentTimeMillis() - (20L * 24 * 60 * 60 * 1000) + + val thirtyDaysMs = 30L * 24 * 60 * 60 * 1000 + val withinWindow = promptState.lastPromptDate != 0L && + System.currentTimeMillis() - promptState.lastPromptDate < thirtyDaysMs + assertThat(withinWindow).isTrue() + } + + @Test + fun `should prompt after 30 days elapsed`() { + promptState.lastPromptDate = System.currentTimeMillis() - (31L * 24 * 60 * 60 * 1000) + + val thirtyDaysMs = 30L * 24 * 60 * 60 * 1000 + val withinWindow = promptState.lastPromptDate != 0L && + System.currentTimeMillis() - promptState.lastPromptDate < thirtyDaysMs + assertThat(withinWindow).isFalse() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspExceptionTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspExceptionTest.kt new file mode 100644 index 00000000000..0019feb21a9 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspExceptionTest.kt @@ -0,0 +1,50 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CfnLspExceptionTest { + + @Test + fun `exception contains message and error code`() { + val exception = CfnLspException( + "Test error message", + CfnLspException.ErrorCode.DOWNLOAD_FAILED + ) + + assertThat(exception.message).isEqualTo("Test error message") + assertThat(exception.errorCode).isEqualTo(CfnLspException.ErrorCode.DOWNLOAD_FAILED) + assertThat(exception.cause).isNull() + } + + @Test + fun `exception contains cause when provided`() { + val cause = RuntimeException("Root cause") + val exception = CfnLspException( + "Wrapper message", + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED, + cause + ) + + assertThat(exception.message).isEqualTo("Wrapper message") + assertThat(exception.errorCode).isEqualTo(CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED) + assertThat(exception.cause).isEqualTo(cause) + } + + @Test + fun `error codes cover all failure scenarios`() { + val errorCodes = CfnLspException.ErrorCode.values() + + assertThat(errorCodes).containsExactlyInAnyOrder( + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED, + CfnLspException.ErrorCode.NO_COMPATIBLE_VERSION, + CfnLspException.ErrorCode.DOWNLOAD_FAILED, + CfnLspException.ErrorCode.EXTRACTION_FAILED, + CfnLspException.ErrorCode.NODE_NOT_FOUND, + CfnLspException.ErrorCode.HASH_VERIFICATION_FAILED + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstallerTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstallerTest.kt new file mode 100644 index 00000000000..31f1aecc745 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstallerTest.kt @@ -0,0 +1,208 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.file.Files +import java.nio.file.Path + +class CfnLspInstallerTest { + + @Rule + @JvmField + val tempFolder = TemporaryFolder() + + @Test + fun `findCachedServer returns null when storage dir does not exist`() { + val installer = installerWithDir("non-existent") + assertThat(installer.findCachedServerForTest()).isNull() + } + + @Test + fun `findCachedServer returns null when no server files present`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + Files.createDirectories(storageDir.resolve("1.2.0")) + + assertThat(CfnLspInstaller(storageDir).findCachedServerForTest()).isNull() + } + + @Test + fun `findCachedServer returns highest compatible version`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.0.0") + createServerVersion(storageDir, "1.2.0") + createServerVersion(storageDir, "1.4.0") + + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.4.0") + } + + @Test + fun `findCachedServer uses semver not lexicographic ordering`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.9.0") + createServerVersion(storageDir, "1.10.0") + + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.10.0") + } + + @Test + fun `findCachedServer excludes versions outside supported range`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + createServerVersion(storageDir, "2.0.0") + createServerVersion(storageDir, "3.0.0") + + // Default range is <2.0.0 + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.4.0") + } + + @Test + fun `findCachedServer returns null when all versions outside range`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "2.0.0") + createServerVersion(storageDir, "3.0.0") + + assertThat(CfnLspInstaller(storageDir).findCachedServerForTest()).isNull() + } + + @Test + fun `findCachedServer skips directories with unparseable names`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "not-a-version") + createServerVersion(storageDir, "1.2.0") + + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.2.0") + } + + // --- cleanupOldVersions --- + + @Test + fun `cleanupOldVersions keeps current version and one fallback`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.0.0") + createServerVersion(storageDir, "1.2.0") + createServerVersion(storageDir, "1.4.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() // current + assertThat(Files.exists(storageDir.resolve("1.2.0"))).isTrue() // fallback + assertThat(Files.exists(storageDir.resolve("1.0.0"))).isFalse() // removed + } + + @Test + fun `cleanupOldVersions removes versions outside supported range`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + createServerVersion(storageDir, "2.0.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() + assertThat(Files.exists(storageDir.resolve("2.0.0"))).isFalse() + } + + @Test + fun `cleanupOldVersions preserves only current when no valid fallback`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() + } + + @Test + fun `cleanupOldVersions handles non-existent storage dir gracefully`() { + val installer = installerWithDir("non-existent") + // Should not throw + installer.cleanupOldVersionsForTest("1.0.0") + } + + @Test + fun `cleanupOldVersions picks highest compatible version as fallback`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.0.0") + createServerVersion(storageDir, "1.1.0") + createServerVersion(storageDir, "1.2.0") + createServerVersion(storageDir, "1.3.0") + createServerVersion(storageDir, "1.4.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() // current + assertThat(Files.exists(storageDir.resolve("1.3.0"))).isTrue() // fallback (highest below current) + assertThat(Files.exists(storageDir.resolve("1.2.0"))).isFalse() + assertThat(Files.exists(storageDir.resolve("1.1.0"))).isFalse() + assertThat(Files.exists(storageDir.resolve("1.0.0"))).isFalse() + } + + @Test + fun `cleanupOldVersions skips unparseable directory names`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + createServerVersion(storageDir, "temp-download") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() + // unparseable dirs get cleaned up since they're not in the keep set + assertThat(Files.exists(storageDir.resolve("temp-download"))).isFalse() + } + + // --- hash utilities --- + + @Test + fun `parseHashString extracts algorithm and hash`() { + assertThat(CfnLspInstaller.parseHashString("sha256:abc123def456")) + .isEqualTo("sha256" to "abc123def456") + } + + @Test + fun `parseHashString returns null for invalid format`() { + assertThat(CfnLspInstaller.parseHashString("invalidhash")).isNull() + } + + @Test + fun `computeHash calculates sha256 correctly`() { + val hash = CfnLspInstaller.computeHash("test data".toByteArray(), "sha256") + assertThat(hash).isEqualTo("916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9") + } + + @Test + fun `computeHash calculates sha384 correctly`() { + val hash = CfnLspInstaller.computeHash("test data".toByteArray(), "sha384") + assertThat(hash).hasSize(96) // SHA-384 produces 96 hex characters + } + + // --- helpers --- + + private fun createServerVersion(storageDir: Path, version: String) { + val dir = storageDir.resolve(version) + Files.createDirectories(dir) + Files.createFile(dir.resolve(CfnLspServerConfig.SERVER_FILE)) + } + + private fun installerWithDir(name: String) = + CfnLspInstaller(tempFolder.root.toPath().resolve(name)) + + private fun CfnLspInstaller.findCachedServerForTest(): Path? { + val method = this::class.java.getDeclaredMethod("findCachedServer") + method.isAccessible = true + return method.invoke(this) as? Path + } + + private fun CfnLspInstaller.cleanupOldVersionsForTest(currentVersion: String) { + val method = this::class.java.getDeclaredMethod("cleanupOldVersions", String::class.java) + method.isAccessible = true + method.invoke(this, currentVersion) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapterTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapterTest.kt new file mode 100644 index 00000000000..530ad328b63 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapterTest.kt @@ -0,0 +1,305 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test + +class GitHubManifestAdapterTest { + + @Test + fun `ManifestVersion data class holds correct values`() { + val version = ManifestVersion( + serverVersion = "1.3.0", + latest = true, + isDelisted = false, + targets = emptyList() + ) + + assertThat(version.serverVersion).isEqualTo("1.3.0") + assertThat(version.latest).isTrue() + assertThat(version.isDelisted).isFalse() + assertThat(version.targets).isEmpty() + } + + @Test + fun `ManifestTarget data class holds correct values`() { + val target = ManifestTarget( + platform = "darwin", + arch = "arm64", + nodejs = "22", + contents = listOf( + ManifestContent( + filename = "server.zip", + url = "https://example.com/server.zip", + bytes = 50000000 + ) + ) + ) + + assertThat(target.platform).isEqualTo("darwin") + assertThat(target.arch).isEqualTo("arm64") + assertThat(target.nodejs).isEqualTo("22") + assertThat(target.contents).hasSize(1) + assertThat(target.contents[0].filename).isEqualTo("server.zip") + } + + @Test + fun `ServerRelease data class holds correct values`() { + val release = ServerRelease( + version = "1.3.0", + downloadUrl = "https://example.com/download.zip", + filename = "server.zip", + size = 50000000, + hashes = listOf("sha256:abc123") + ) + + assertThat(release.version).isEqualTo("1.3.0") + assertThat(release.downloadUrl).isEqualTo("https://example.com/download.zip") + assertThat(release.filename).isEqualTo("server.zip") + assertThat(release.size).isEqualTo(50000000) + assertThat(release.hashes).containsExactly("sha256:abc123") + } + + @Test + fun `remapLegacyLinux replaces linux with linuxglib2_28`() { + val versions = listOf( + ManifestVersion( + serverVersion = "1.0.0", + targets = listOf( + ManifestTarget("linux", "x64", "22", listOf(ManifestContent("a.zip", "url", emptyList(), 100))), + ManifestTarget("linuxglib2.28", "x64", "18", listOf(ManifestContent("b.zip", "url", emptyList(), 100))), + ManifestTarget("darwin", "x64", "22", listOf(ManifestContent("c.zip", "url", emptyList(), 100))), + ) + ) + ) + + val remapped = GitHubManifestAdapter.remapLegacyLinux(versions) + + assertThat(remapped[0].targets.map { it.platform }).containsExactlyInAnyOrder("linux", "darwin") + assertThat(remapped[0].targets.first { it.platform == "linux" }.nodejs).isEqualTo("18") + } + + @Test + fun `remapLegacyLinux preserves version without legacy target`() { + val versions = listOf( + ManifestVersion( + serverVersion = "1.0.0", + targets = listOf( + ManifestTarget("linux", "x64", "22", listOf(ManifestContent("a.zip", "url", emptyList(), 100))), + ManifestTarget("darwin", "x64", "22", listOf(ManifestContent("c.zip", "url", emptyList(), 100))), + ) + ) + ) + + val remapped = GitHubManifestAdapter.remapLegacyLinux(versions) + + assertThat(remapped[0].targets.map { it.platform }).containsExactlyInAnyOrder("linux", "darwin") + } + + @Test + fun `parseManifest prefers version marked as latest`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("1.4.0", true, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest falls back to semver sort when no latest flag`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + // No version marked as latest — should pick highest by semver + val manifest = buildManifestJson( + "prod", + listOf("1.2.0", "1.4.0", "1.0.0", "1.3.1") + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest skips latest if outside version range`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("2.0.0", true, false), + Triple("1.4.0", false, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest skips latest if delisted`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("1.4.0", true, true), + Triple("1.3.0", false, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.3.0") + } + + @Test + fun `parseManifest respects version range and excludes 2_x`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJson( + "prod", + listOf("2.1.0", "2.0.0", "1.4.0", "1.2.0") + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest skips delisted versions`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("1.4.0", false, true), + Triple("1.3.0", false, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.3.0") + } + + @Test + fun `parseManifest errors when no compatible version exists`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<1.0.0"), + ) + + val manifest = buildManifestJson("prod", listOf("1.4.0", "1.2.0")) + + assertThatThrownBy { adapter.parseManifest(manifest) } + .hasMessageContaining("No compatible version found") + } + + @Test + fun `parseManifest handles beta versions correctly`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.BETA, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJson( + "beta", + listOf("1.4.0-beta", "1.2.0-beta") + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0-beta") + } + + @Test + fun `parseManifest numeric sort not lexicographic`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<100.0.0"), + ) + + val manifest = buildManifestJson("prod", listOf("9.0.0", "10.0.0", "2.0.0")) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("10.0.0") + } + + // --- helpers --- + + private fun buildManifestJson(env: String, versions: List): String = + buildManifestJsonFull(env, versions.map { Triple(it, false, false) }) + + private fun buildManifestJsonFull( + env: String, + versions: List>, + ): String { + val mapper = jacksonObjectMapper() + val versionObjects = versions.map { (v, latest, delisted) -> + mapOf( + "serverVersion" to v, + "latest" to latest, + "isDelisted" to delisted, + "targets" to listOf(currentPlatformTarget(v)) + ) + } + return mapper.writeValueAsString(mapOf(env to versionObjects)) + } + + private fun currentPlatformTarget(version: String): Map { + val os = System.getProperty("os.name").lowercase().let { + when { + it.contains("mac") -> "darwin" + it.contains("win") -> "windows" + else -> "linux" + } + } + val arch = System.getProperty("os.arch").lowercase().let { + when { + it.contains("aarch64") || it.contains("arm64") -> "arm64" + else -> "x64" + } + } + return mapOf( + "platform" to os, + "arch" to arch, + "contents" to listOf( + mapOf( + "filename" to "server-$version.zip", + "url" to "https://example.com/server-$version.zip", + "hashes" to emptyList(), + "bytes" to 50000000 + ) + ) + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetectorTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetectorTest.kt new file mode 100644 index 00000000000..ec2583310f8 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetectorTest.kt @@ -0,0 +1,60 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class LegacyLinuxDetectorTest { + + @Test + fun `parseGlibcxxVersions extracts versions from strings output`() { + val output = """ + GLIBCXX_3.4 + GLIBCXX_3.4.1 + GLIBCXX_3.4.29 + GLIBCXX_3.4.30 + some other text + """.trimIndent() + + val detector = LegacyLinuxDetector() + val versions = detector.parseGlibcxxVersions(output) + + assertThat(versions).containsExactlyInAnyOrder( + listOf(3, 4), + listOf(3, 4, 1), + listOf(3, 4, 29), + listOf(3, 4, 30) + ) + } + + @Test + fun `parseGlibcxxVersions returns empty for no matches`() { + val detector = LegacyLinuxDetector() + val versions = detector.parseGlibcxxVersions("no versions here") + + assertThat(versions).isEmpty() + } + + @Test + fun `compareVersions returns negative when first is less`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4, 28), listOf(3, 4, 29))).isNegative() + } + + @Test + fun `compareVersions returns positive when first is greater`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4, 30), listOf(3, 4, 29))).isPositive() + } + + @Test + fun `compareVersions returns zero when equal`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4, 29), listOf(3, 4, 29))).isZero() + } + + @Test + fun `compareVersions handles different length versions`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4), listOf(3, 4, 0))).isZero() + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4), listOf(3, 4, 1))).isNegative() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVerTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVerTest.kt new file mode 100644 index 00000000000..6b22ebc6213 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVerTest.kt @@ -0,0 +1,136 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class SemVerTest { + + @Test + fun `parse standard version`() { + val v = checkNotNull(SemVer.parse("1.4.0")) + assertThat(v.major).isEqualTo(1) + assertThat(v.minor).isEqualTo(4) + assertThat(v.patch).isEqualTo(0) + assertThat(v.prerelease).isEmpty() + } + + @Test + fun `parse version with v prefix`() { + val v = checkNotNull(SemVer.parse("v1.4.0")) + assertThat(v.major).isEqualTo(1) + assertThat(v.minor).isEqualTo(4) + } + + @Test + fun `parse version with prerelease`() { + val v = checkNotNull(SemVer.parse("1.4.0-beta")) + assertThat(v.prerelease).containsExactly("beta") + } + + @Test + fun `parse returns null for invalid input`() { + assertThat(SemVer.parse("not-a-version")).isNull() + assertThat(SemVer.parse("1.2")).isNull() + assertThat(SemVer.parse("")).isNull() + assertThat(SemVer.parse("abc.def.ghi")).isNull() + } + + @Test + fun `comparison - major version difference`() { + assertThat(checkNotNull(SemVer.parse("2.0.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.9.9"))) + } + + @Test + fun `comparison - minor version difference`() { + assertThat(checkNotNull(SemVer.parse("1.5.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.4.9"))) + } + + @Test + fun `comparison - patch version difference`() { + assertThat(checkNotNull(SemVer.parse("1.4.1"))).isGreaterThan(checkNotNull(SemVer.parse("1.4.0"))) + } + + @Test + fun `comparison - release beats prerelease`() { + assertThat(checkNotNull(SemVer.parse("1.4.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.4.0-beta"))) + } + + @Test + fun `comparison - equal versions`() { + assertThat(checkNotNull(SemVer.parse("1.4.0"))).isEqualByComparingTo(checkNotNull(SemVer.parse("1.4.0"))) + } + + @Test + fun `comparison - v prefix ignored`() { + assertThat(checkNotNull(SemVer.parse("v1.4.0"))).isEqualByComparingTo(checkNotNull(SemVer.parse("1.4.0"))) + } + + @Test + fun `comparison - 10 is greater than 9 (not lexicographic)`() { + assertThat(checkNotNull(SemVer.parse("10.0.0"))).isGreaterThan(checkNotNull(SemVer.parse("9.0.0"))) + assertThat(checkNotNull(SemVer.parse("1.10.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.9.0"))) + } + + @Test + fun `sorting produces correct order`() { + val versions = listOf("1.0.0", "1.4.0", "1.2.0", "1.3.1", "1.1.0") + .map { checkNotNull(SemVer.parse(it)) } + .sortedDescending() + .map { "${it.major}.${it.minor}.${it.patch}" } + + assertThat(versions).containsExactly("1.4.0", "1.3.1", "1.2.0", "1.1.0", "1.0.0") + } + + @Test + fun `sorting with prereleases`() { + val versions = listOf("1.4.0", "1.4.0-beta", "1.3.1", "1.3.1-beta") + .map { checkNotNull(SemVer.parse(it)) } + .sortedDescending() + .map { + "${it.major}.${it.minor}.${it.patch}" + + if (it.prerelease.isNotEmpty()) "-${it.prerelease.joinToString("-")}" else "" + } + + assertThat(versions).containsExactly("1.4.0", "1.4.0-beta", "1.3.1", "1.3.1-beta") + } +} + +class SemVerRangeTest { + + @Test + fun `less than range`() { + val range = SemVerRange.parse("<2.0.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.4.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.99.99")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.0")))).isFalse() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.1")))).isFalse() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("3.0.0")))).isFalse() + } + + @Test + fun `less than range includes prereleases`() { + val range = SemVerRange.parse("<2.0.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.4.0-beta")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.0-beta")))).isFalse() + } + + @Test + fun `greater than or equal range`() { + val range = SemVerRange.parse(">=1.2.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.2.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.4.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.1.0")))).isFalse() + } + + @Test + fun `combined range`() { + val range = SemVerRange.parse(">=1.0.0 <2.0.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.0.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.99.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("0.9.0")))).isFalse() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.0")))).isFalse() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt index 6e5cdd0d037..89ae2e3f5e7 100644 --- a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt @@ -3,10 +3,12 @@ package software.aws.toolkits.jetbrains.services.lambda.java +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.roots.ModuleRootManagerEx import com.intellij.openapi.roots.ModuleRootModificationUtil import com.intellij.testFramework.IdeaTestUtil +import com.intellij.testFramework.common.ThreadLeakTracker import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -40,6 +42,9 @@ class JavaLambdaBuilderTest { setSamExecutableFromEnvironment() projectRule.fixture.addModule("main") + + // Maven server threads may leak during test execution + ThreadLeakTracker.longRunningThreadCreated(ApplicationManager.getApplication(), "BaseDataReader", "RemoteMavenServer", "Reader thread") } @Test diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/settings/CfnLspSettingsTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/settings/CfnLspSettingsTest.kt new file mode 100644 index 00000000000..b881b842703 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/settings/CfnLspSettingsTest.kt @@ -0,0 +1,71 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.testFramework.ApplicationRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test + +class CfnLspSettingsTest { + + @Rule + @JvmField + val applicationRule = ApplicationRule() + + @Test + fun `default settings have expected values`() { + val settings = CfnLspSettings.getInstance() + + assertThat(settings.nodeRuntimePath).isEmpty() + assertThat(settings.isTelemetryEnabled).isFalse() + assertThat(settings.isHoverEnabled).isTrue() + assertThat(settings.isCompletionEnabled).isTrue() + assertThat(settings.maxCompletions).isEqualTo(100) + } + + @Test + fun `cfn-lint default settings`() { + val settings = CfnLspSettings.getInstance() + + assertThat(settings.isCfnLintEnabled).isTrue() + assertThat(settings.cfnLintLintOnChange).isTrue() + assertThat(settings.cfnLintDelayMs).isEqualTo(3000) + assertThat(settings.cfnLintIncludeChecks).isEqualTo("I") + assertThat(settings.cfnLintIncludeExperimental).isFalse() + } + + @Test + fun `cfn-guard default settings`() { + val settings = CfnLspSettings.getInstance() + + assertThat(settings.isCfnGuardEnabled).isTrue() + assertThat(settings.cfnGuardValidateOnChange).isTrue() + assertThat(settings.cfnGuardEnabledRulePacks).isEqualTo("wa-Security-Pillar") + assertThat(settings.cfnGuardRulesFile).isEmpty() + } + + @Test + fun `settings can be modified`() { + val settings = CfnLspSettings.getInstance() + + settings.nodeRuntimePath = "/usr/bin/node" + assertThat(settings.nodeRuntimePath).isEqualTo("/usr/bin/node") + + settings.maxCompletions = 50 + assertThat(settings.maxCompletions).isEqualTo(50) + + // Reset to defaults + settings.nodeRuntimePath = "" + settings.maxCompletions = 100 + } + + @Test + fun `guard rule packs list is populated`() { + assertThat(CfnLspSettings.GUARD_RULE_PACKS).isNotEmpty() + assertThat(CfnLspSettings.GUARD_RULE_PACKS).contains("wa-Security-Pillar") + assertThat(CfnLspSettings.GUARD_RULE_PACKS).contains("hipaa-security") + assertThat(CfnLspSettings.GUARD_RULE_PACKS).contains("cis-aws-benchmark-level-1") + } +} diff --git a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt b/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt index e3c627e34b1..d1576bbbea0 100644 --- a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt +++ b/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt @@ -30,7 +30,7 @@ class GatewayDeprecationNotice : AppLifecycleListener { } companion object { - const val MIN_VERSION = 252 - const val MIN_VERSION_HUMAN = "2025.2" + const val MIN_VERSION = 253 + const val MIN_VERSION_HUMAN = "2025.3" } } diff --git a/plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Protocol/ModelZoneMarket.cs b/plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/ModelZoneMarket.cs similarity index 100% rename from plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Protocol/ModelZoneMarket.cs rename to plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/ModelZoneMarket.cs diff --git a/plugins/toolkit/jetbrains-rider/build.gradle.kts b/plugins/toolkit/jetbrains-rider/build.gradle.kts index 6e624db252c..07d3f161ee9 100644 --- a/plugins/toolkit/jetbrains-rider/build.gradle.kts +++ b/plugins/toolkit/jetbrains-rider/build.gradle.kts @@ -48,10 +48,7 @@ sourceSets { } } -// FIX_WHEN_MIN_IS_251 -// org.gradle.internal.resolve.ModuleVersionNotFoundException: -// Could not find any version that matches com.jetbrains.intellij.platform:test-framework:{strictly [243, 243.21565.192]; prefer 243.21565.192}. -if (providers.gradleProperty("ideProfileName").get() == "2024.3") { +if (providers.gradleProperty("ideProfileName").get() == "2025.3") { configurations.all { resolutionStrategy.dependencySubstitution { listOf( @@ -60,8 +57,8 @@ if (providers.gradleProperty("ideProfileName").get() == "2024.3") { "com.jetbrains.intellij.platform:test-framework-junit5" ).forEach { substitute(module(it)) - .using(module("$it:243.21565.193")) - .because("Rider 2024.3.0 requires a newer version of test-framework") + .using(module("$it:253.28294.334")) + .because("Rider 2025.3.0 requires a newer version of test-framework") } } } @@ -78,9 +75,9 @@ dependencies { localPlugin(project(":plugin-core")) testFramework(TestFrameworkType.Bundled) - // FIX_WHEN_MIN_IS_251: https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1774 + // FIX_WHEN_MIN_IS_253: https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1774 when (providers.gradleProperty("ideProfileName").get()) { - "2024.3", "2025.1" -> { + "2025.1, 2025.3" -> { bundledModule("intellij.rider") } } diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt b/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt index 46a036a334a..3fabe1af3c6 100644 --- a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt +++ b/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt @@ -17,10 +17,16 @@ import software.aws.toolkits.jetbrains.services.lambda.SdkBasedRuntimeGroup class NodeJsRuntimeGroup : SdkBasedRuntimeGroup() { override val id: String = BuiltInRuntimeGroups.NodeJs - override val languageIds: Set = setOf( - JavascriptLanguage.id, - JavaScriptSupportLoader.ECMA_SCRIPT_6.id - ) + override val languageIds: Set by lazy { + try { + setOf( + JavascriptLanguage.id, + JavaScriptSupportLoader.ECMA_SCRIPT_6.id + ) + } catch (e: Throwable) { + emptySet() + } + } override val supportsPathMappings: Boolean = true override val supportedRuntimes = listOf( diff --git a/settings.gradle.kts b/settings.gradle.kts index 3c13798d46c..68520bfa5c7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,12 +50,6 @@ dependencyResolutionManagement { repositories { codeArtifactMavenRepo() mavenCentral() - maven { - url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") - content { - includeGroupByRegex("org\\.mockito\\.kotlin") - } - } intellijPlatform { defaultRepositories() @@ -167,7 +161,7 @@ file("plugins").listFiles()?.forEach root@ { if (it.name == "jetbrains-gateway") { when (providers.gradleProperty("ideProfileName").get()) { // buildSrc is evaluated after settings so we can't key off of IdeVersions.kt - "2024.3", "2025.1" -> { + "2025.1", "2025.2" -> { return@forEach } } diff --git a/ui-tests-starter/build.gradle.kts b/ui-tests-starter/build.gradle.kts index 5bd6f620958..7d7b6bf76a4 100644 --- a/ui-tests-starter/build.gradle.kts +++ b/ui-tests-starter/build.gradle.kts @@ -60,7 +60,12 @@ dependencies { intellijPlatform { val version = ideProfile.community.sdkVersion - intellijIdeaCommunity(version, !version.contains("SNAPSHOT")) + // Use unified IntelliJ IDEA for 2025.3+, Community for older versions + if (version.startsWith("2025.3")) { + intellijIdeaUltimate(version, !version.contains("SNAPSHOT")) + } else { + intellijIdeaCommunity(version, !version.contains("SNAPSHOT")) + } localPlugin(project(":plugin-core")) testImplementation(project(":plugin-core:core"))