From 9eab8ef77c58f1a225fcbae43f02c4c2e32e353b Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 12:05:58 -0800 Subject: [PATCH 01/35] Add 2025.3 support with build 253.28294 --- .../toolkits/gradle/intellij/IdeVersions.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 0fd34e946d6..1d1aa477424 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 @@ -152,6 +152,50 @@ object IdeVersions { rdGenVersion = "2025.2.2", nugetVersion = "2025.2.0" ) + ), + Profile( + name = "2025.3", + gateway = ProductProfile( + sdkVersion = "253.28294.92", + bundledPlugins = listOf("org.jetbrains.plugins.terminal") + ), + community = ProductProfile( + sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + bundledPlugins = commonPlugins + listOf( + "com.intellij.java", + "com.intellij.gradle", + "org.jetbrains.idea.maven", + "com.jetbrains.codeWithMe", + "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" + ) + ), + ultimate = ProductProfile( + sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + bundledPlugins = commonPlugins + listOf( + "JavaScript", + "JavaScriptDebugger", + "com.intellij.database", + "com.jetbrains.codeWithMe", + ), + marketplacePlugins = listOf( + "Pythonid:253.28294.51", + "org.jetbrains.plugins.go:253.28294.51", + "com.intellij.modules.json:253.28294.51" + ) + ), + rider = RiderProfile( + sdkVersion = "2025.3-SNAPSHOT", + bundledPlugins = commonPlugins, + netFrameworkTarget = "net472", + rdGenVersion = "2025.3.1", + nugetVersion = "2025.3.0" + ) ) ).associateBy { it.name } From 0642e8515fee96d23859ff261cf638da0f509866 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 12:14:48 -0800 Subject: [PATCH 02/35] Configure Kotlin 2.1 and coroutines for 2025.3 --- .../kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt | 2 +- kotlinResolution.settings.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 8432d3e20cc..30d3187286c 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt @@ -29,7 +29,7 @@ fun Project.jvmTarget(): Provider = withCurrentProfileName { fun Project.kotlinTarget(): Provider = withCurrentProfileName { when (it) { "2024.3" -> KotlinVersionEnum.KOTLIN_2_0 - "2025.1", "2025.2" -> KotlinVersionEnum.KOTLIN_2_1 + "2025.1", "2025.2", "2025.3" -> KotlinVersionEnum.KOTLIN_2_1 else -> error("not set") }.version } diff --git a/kotlinResolution.settings.gradle.kts b/kotlinResolution.settings.gradle.kts index ccc4ee9d06f..3023d2002d2 100644 --- a/kotlinResolution.settings.gradle.kts +++ b/kotlinResolution.settings.gradle.kts @@ -10,7 +10,7 @@ dependencyResolutionManagement { "1.8.0-intellij-11" } - "2025.2" -> { + "2025.2", "2025.3" -> { "1.10.1-intellij-5" } From 72f8a3e5890f75066ffded6010c8c6302c89f173 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 14:39:58 -0800 Subject: [PATCH 03/35] Remove codeWithMe dependency for 2025.3 - Remove com.jetbrains.codeWithMe from bundledPlugins (not available in 2025.3 EAP) - Version-segregate RebuildDevfileRequiredNotification to src-253+ (Code With Me APIs unavailable) --- .../toolkits/gradle/intellij/IdeVersions.kt | 2 -- .../RebuildDevfileRequiredNotification.kt | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt 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 1d1aa477424..d97b59c6a31 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 @@ -165,7 +165,6 @@ object IdeVersions { "com.intellij.java", "com.intellij.gradle", "org.jetbrains.idea.maven", - "com.jetbrains.codeWithMe", "com.intellij.properties" ), marketplacePlugins = listOf( @@ -181,7 +180,6 @@ object IdeVersions { "JavaScript", "JavaScriptDebugger", "com.intellij.database", - "com.jetbrains.codeWithMe", ), marketplacePlugins = listOf( "Pythonid:253.28294.51", diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt new file mode 100644 index 00000000000..2420e02e516 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt @@ -0,0 +1,34 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.remoteDev.caws + +// TODO: Re-enable when RD platform APIs are available in 2025.3 +// The com.jetbrains.rd.platform.codeWithMe APIs are not available in 2025.3 EAP +// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.Metric +// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricType +// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricsStatus +// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.providers.MetricProvider +import software.aws.toolkits.resources.message + +/* +class RebuildDevfileRequiredNotification : MetricProvider { + override val id: String + get() = "devfileRebuildRequired" + + override fun getMetrics(): List = listOf( + object : Metric { + override val id: String + get() = "devfileRebuildRequired" + override val type: MetricType + get() = MetricType.PERFORMANCE + override val status: MetricsStatus + get() = MetricsStatus.RED + } + ) + + inner class DevfileRebuildRequiredMetric : Metric { + override fun toString(): String = message("caws.rebuild.workspace.notification") + } +} +*/ From a0ef369f7c07d9626ebd1cfd14ab01b9c0361d2b Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 15:26:17 -0800 Subject: [PATCH 04/35] Fix detekt: remove unused import in 253+ version --- .../remoteDev/caws/RebuildDevfileRequiredNotification.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt index 2420e02e516..5ec08592773 100644 --- a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt @@ -9,7 +9,6 @@ package software.aws.toolkits.jetbrains.remoteDev.caws // import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricType // import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricsStatus // import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.providers.MetricProvider -import software.aws.toolkits.resources.message /* class RebuildDevfileRequiredNotification : MetricProvider { From 42db4e8e8b737f58d99efdab01d2f86b1dc2168b Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 15:34:19 -0800 Subject: [PATCH 05/35] Fix Gateway SDK version for 2025.3 Use version string (253.28294-EAP-CANDIDATE-SNAPSHOT) instead of build number (253.28294.92) --- .../kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d97b59c6a31..17f4e22e033 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 @@ -156,7 +156,7 @@ object IdeVersions { Profile( name = "2025.3", gateway = ProductProfile( - sdkVersion = "253.28294.92", + sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", bundledPlugins = listOf("org.jetbrains.plugins.terminal") ), community = ProductProfile( From 8d4b06d86e8ac535448363acf986c3f7fe38796e Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 16:13:45 -0800 Subject: [PATCH 06/35] Fix gateway sdkVersion with the correct build number --- .../kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 17f4e22e033..053f3d56f08 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 @@ -156,7 +156,7 @@ object IdeVersions { Profile( name = "2025.3", gateway = ProductProfile( - sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + sdkVersion = "253.28086.53", bundledPlugins = listOf("org.jetbrains.plugins.terminal") ), community = ProductProfile( From 9faaca4dfa5836e80770c7ed9bd2fe26170811de Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 18:32:15 -0800 Subject: [PATCH 07/35] Bundle collaboration modules for 2025.3 OAuth APIs split into separate modules in 253: - intellij.platform.collaborationTools - intellij.platform.collaborationTools.auth.base - intellij.platform.collaborationTools.auth These must be explicitly bundled for compilation. --- .../src/main/kotlin/toolkit-intellij-subplugin.gradle.kts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts index c2273e58c45..a832242651d 100644 --- a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts @@ -101,6 +101,14 @@ dependencies { bundledPlugins(toolkitIntelliJ.productProfile().map { it.bundledPlugins }) plugins(toolkitIntelliJ.productProfile().map { it.marketplacePlugins }) + // OAuth modules split in 2025.3 (253) - must be explicitly bundled + val versionStr = version.get() + if (versionStr.contains("253")) { + bundledModule("intellij.platform.collaborationTools") + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } + testFramework(TestFrameworkType.Plugin.Java) testFramework(TestFrameworkType.Platform) testFramework(TestFrameworkType.JUnit5) From 550881f40b50ba86ffac189a48079b58998b233c Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 19:00:12 -0800 Subject: [PATCH 08/35] Version segregate OTelService for 2025.3 httpPost API signature incompatibility in coroutine context. Use ByteArray overload instead of OutputStream lambda. - src/: Original implementation for 2024.3-2025.2 - src-253+/: Updated for 2025.3+ using httpPost ByteArray overload --- .../services/telemetry/otel/OTelService.kt | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 plugins/core/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt diff --git a/plugins/core/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt b/plugins/core/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt new file mode 100644 index 00000000000..698e6e43b95 --- /dev/null +++ b/plugins/core/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt @@ -0,0 +1,171 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +@file:Suppress("UnusedPrivateClass") + +package software.aws.toolkits.jetbrains.services.telemetry.otel + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.util.SystemInfoRt +import com.intellij.platform.util.http.ContentType +import com.intellij.platform.util.http.httpPost +import com.intellij.serviceContainer.NonInjectable +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.ContextPropagators +import io.opentelemetry.exporter.internal.otlp.traces.TraceRequestMarshaler +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.opentelemetry.sdk.trace.ReadableSpan +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.SpanProcessor +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.http.ContentStreamProvider +import software.amazon.awssdk.http.HttpExecuteRequest +import software.amazon.awssdk.http.SdkHttpMethod +import software.amazon.awssdk.http.SdkHttpRequest +import software.amazon.awssdk.http.apache.ApacheHttpClient +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner +import java.io.ByteArrayOutputStream +import java.net.ConnectException + +private class BasicOtlpSpanProcessor( + private val coroutineScope: CoroutineScope, + private val traceUrl: String = "http://127.0.0.1:4318/v1/traces", +) : SpanProcessor { + override fun onStart(parentContext: Context, span: ReadWriteSpan) {} + override fun isStartRequired() = false + override fun isEndRequired() = true + + override fun onEnd(span: ReadableSpan) { + val data = span.toSpanData() + coroutineScope.launch { + try { + val item = TraceRequestMarshaler.create(listOf(data)) + val output = ByteArrayOutputStream() + item.writeBinaryTo(output) + + httpPost(traceUrl, contentType = ContentType.XProtobuf, body = output.toByteArray()) + } catch (e: CancellationException) { + throw e + } catch (e: ConnectException) { + thisLogger().warn("Cannot export (url=$traceUrl): ${e.message}") + } catch (e: Throwable) { + thisLogger().error("Cannot export (url=$traceUrl)", e) + } + } + } +} + +private class SigV4OtlpSpanProcessor( + private val coroutineScope: CoroutineScope, + private val traceUrl: String, + private val creds: AwsCredentialsProvider, +) : SpanProcessor { + override fun onStart(parentContext: Context, span: ReadWriteSpan) {} + override fun isStartRequired() = false + override fun isEndRequired() = true + + private val client = ApacheHttpClient.create() + + override fun onEnd(span: ReadableSpan) { + coroutineScope.launch { + val data = span.toSpanData() + try { + val item = TraceRequestMarshaler.create(listOf(data)) + // calculate the sigv4 header + val signer = AwsV4HttpSigner.create() + val httpRequest = + SdkHttpRequest.builder() + .uri(traceUrl) + .method(SdkHttpMethod.POST) + .putHeader("Content-Type", "application/x-protobuf") + .build() + + val baos = ByteArrayOutputStream() + item.writeBinaryTo(baos) + val payload = ContentStreamProvider.fromByteArray(baos.toByteArray()) + val signedRequest = signer.sign { + it.identity(creds.resolveIdentity().get()) + it.request(httpRequest) + it.payload(payload) + it.putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "osis") + it.putProperty(AwsV4HttpSigner.REGION_NAME, "us-west-2") + } + + // Create and HTTP client and send the request. ApacheHttpClient requires the 'apache-client' module. + client.prepareRequest( + HttpExecuteRequest.builder() + .request(signedRequest.request()) + .contentStreamProvider(signedRequest.payload().orElse(null)) + .build() + ).call() + } catch (e: CancellationException) { + throw e + } catch (e: ConnectException) { + thisLogger().warn("Cannot export (url=$traceUrl): ${e.message}") + } catch (e: Throwable) { + thisLogger().error("Cannot export (url=$traceUrl)", e) + } + } + } +} + +private object StdoutSpanProcessor : SpanProcessor { + override fun onStart(parentContext: Context, span: ReadWriteSpan) {} + override fun isStartRequired() = false + override fun isEndRequired() = true + + override fun onEnd(span: ReadableSpan) { + println(span.toSpanData()) + } +} + +@Service +class OTelService @NonInjectable internal constructor(spanProcessors: List) : Disposable { + @Suppress("unused") + constructor() : this(listOf(ToolkitTelemetryOTelSpanProcessor())) + + private val sdkDelegate = lazy { + OpenTelemetrySdk.builder() + .setTracerProvider( + SdkTracerProvider.builder() + .apply { + spanProcessors.forEach { + addSpanProcessor(it) + } + } + .setResource( + Resource.create( + Attributes.builder() + .put(AttributeKey.stringKey("os.type"), SystemInfoRt.OS_NAME) + .put(AttributeKey.stringKey("os.version"), SystemInfoRt.OS_VERSION) + .put(AttributeKey.stringKey("host.arch"), System.getProperty("os.arch")) + .build() + ) + ) + .build() + ) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build() + } + internal val sdk: OpenTelemetrySdk by sdkDelegate + + override fun dispose() { + if (sdkDelegate.isInitialized()) { + sdk.close() + } + } + + companion object { + fun getSdk() = service().sdk + } +} From 8397fb9cc2e4ee47c576e97be27aeb606844a1a6 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Tue, 11 Nov 2025 19:26:02 -0800 Subject: [PATCH 09/35] Fix OTelService redeclaration error Move OTelService out of base src/ to version-specific folders: - src-242-252/: Original implementation for 2024.2-2025.2 - src-253+/: Updated implementation for 2025.3+ Gradle source sets include base src/ by default, causing redeclaration when src-253+ also exists. Files must be removed from src/ and placed in version ranges. --- .../aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/core/jetbrains-community/{src => src-242-252}/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt (100%) diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt b/plugins/core/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt similarity index 100% rename from plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt rename to plugins/core/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt From 3a5683e6306a1ce810c83145a9ad41fca1e8f48f Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Wed, 12 Nov 2025 14:29:17 -0800 Subject: [PATCH 10/35] Fix RebuildDevfileRequiredNotification redeclaration Move out of base src/ to version-specific folders: - src-242-252/: Working implementation for 2024.2-2025.2 - src-253+/: Commented out for 2025.3 (Code With Me APIs unavailable) Same pattern as OTelService - base src/ always included causes redeclaration when version-specific folders exist. --- .../remoteDev/caws/RebuildDevfileRequiredNotification.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/toolkit/jetbrains-ultimate/{src => src-242-252}/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt (100%) diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt From d8f5d3c4d5bb45b4fcde53987cefac09d33a3e45 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Wed, 12 Nov 2025 15:35:46 -0800 Subject: [PATCH 11/35] Version segregate Gateway connection files for 2025.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace deprecated RD platform APIs with standard Kotlin coroutines: - launchChildSyncIOBackground → scope.launch / lifetime.launch - startChildSyncIOBackgroundAsync → lifetime.launch Files: - src-242-252/: Original implementation for 2024.2-2025.2 - src-253+/: Updated for 2025.3+ with standard coroutines Affects: CawsConnectionProvider.kt, CawsConnectorViewPanels.kt Functionality: Identical - only internal API changes --- .../gateway/CawsConnectionProvider.kt | 0 .../gateway/CawsConnectorViewPanels.kt | 0 .../gateway/CawsConnectionProvider.kt | 617 +++++++++++++++++ .../gateway/CawsConnectorViewPanels.kt | 646 ++++++++++++++++++ 4 files changed, 1263 insertions(+) rename plugins/toolkit/jetbrains-gateway/{src => src-242-252}/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt (100%) rename plugins/toolkit/jetbrains-gateway/{src => src-242-252}/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt (100%) create mode 100644 plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt create mode 100644 plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt diff --git a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt b/plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt similarity index 100% rename from plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt rename to plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt diff --git a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt b/plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt similarity index 100% rename from plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt rename to plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt diff --git a/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt new file mode 100644 index 00000000000..693bf20ce07 --- /dev/null +++ b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt @@ -0,0 +1,617 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.gateway + +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.rd.createNestedDisposable +import com.intellij.openapi.rd.util.launchOnUi +import com.intellij.openapi.rd.util.startUnderBackgroundProgressAsync +import com.intellij.openapi.rd.util.startUnderModalProgressAsync +import com.intellij.openapi.ui.DialogBuilder +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.BuildNumber +import com.intellij.openapi.util.Disposer +import com.intellij.remoteDev.downloader.CodeWithMeClientDownloader +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBFont +import com.jetbrains.gateway.api.ConnectionRequestor +import com.jetbrains.gateway.api.GatewayConnectionHandle +import com.jetbrains.gateway.api.GatewayConnectionProvider +import com.jetbrains.gateway.api.GatewayUI +import com.jetbrains.rd.framework.util.launch +import com.jetbrains.rd.util.lifetime.Lifetime +import com.jetbrains.rd.util.lifetime.LifetimeDefinition +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.await +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentStatus +import software.aws.toolkits.core.utils.AttributeBagKey +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.tryOrNull +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.AwsPlugin +import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.logoutFromSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.CODECATALYST_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sono.CodeCatalystCredentialManager +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId +import software.aws.toolkits.jetbrains.gateway.connection.GET_IDE_BACKEND_VERSION_COMMAND +import software.aws.toolkits.jetbrains.gateway.connection.GitSettings +import software.aws.toolkits.jetbrains.gateway.connection.IDE_BACKEND_DIR +import software.aws.toolkits.jetbrains.gateway.connection.caws.CawsCommandExecutor +import software.aws.toolkits.jetbrains.gateway.connection.workflow.CloneCode +import software.aws.toolkits.jetbrains.gateway.connection.workflow.CopyScripts +import software.aws.toolkits.jetbrains.gateway.connection.workflow.InstallPluginBackend.InstallLocalPluginBackend +import software.aws.toolkits.jetbrains.gateway.connection.workflow.InstallPluginBackend.InstallMarketplacePluginBackend +import software.aws.toolkits.jetbrains.gateway.connection.workflow.PrimeSshAgent +import software.aws.toolkits.jetbrains.gateway.connection.workflow.TabbedWorkflowEmitter +import software.aws.toolkits.jetbrains.gateway.connection.workflow.installBundledPluginBackend +import software.aws.toolkits.jetbrains.gateway.connection.workflow.v2.StartBackendV2 +import software.aws.toolkits.jetbrains.gateway.welcomescreen.WorkspaceListStateChangeContext +import software.aws.toolkits.jetbrains.gateway.welcomescreen.WorkspaceNotifications +import software.aws.toolkits.jetbrains.services.caws.CawsProject +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.jetbrains.utils.execution.steps.StepExecutor +import software.aws.toolkits.jetbrains.utils.execution.steps.StepWorkflow +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodecatalystTelemetry +import java.net.URLDecoder +import java.time.Duration +import java.util.UUID +import javax.swing.JLabel +import kotlin.time.DurationUnit +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue +import software.aws.toolkits.telemetry.Result as TelemetryResult + +@ExperimentalTime +class CawsConnectionProvider : GatewayConnectionProvider { + private val scope = CoroutineScope(getCoroutineBgContext() + SupervisorJob()) + + companion object { + val CAWS_CONNECTION_PARAMETERS = AttributeBagKey.create>("CAWS_CONNECTION_PARAMETERS") + private val LOG = getLogger() + } + + override fun isApplicable(parameters: Map): Boolean = parameters.containsKey(CawsConnectionParameters.CAWS_ENV_ID) + + override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { + val connectionParams = try { + CawsConnectionParameters.fromParameters(parameters) + } catch (e: Exception) { + LOG.error(e) { "Caught exception while building connection settings" } + Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message("caws.workspace.connection.failed")) + return null + } + + val currentConnection = service().activeConnectionForFeature(CodeCatalystConnection.getInstance()) + as AwsBearerTokenConnection? + + val ssoSettings = connectionParams.ssoSettings ?: SsoSettings(SONO_URL, SONO_REGION) + + if (currentConnection != null) { + if (ssoSettings.startUrl != currentConnection.startUrl) { + val ans = Messages.showOkCancelDialog( + message("gateway.auth.different.account.required", ssoSettings.startUrl), + message("gateway.auth.different.account.sign.in"), + message("caws.login"), + message("general.cancel"), + Messages.getErrorIcon(), + null + ) + if (ans == Messages.OK) { + logoutFromSsoConnection(project = null, currentConnection) + loginSso(project = null, ssoSettings.startUrl, ssoSettings.region, CODECATALYST_SCOPES) + } else { + return null + } + } + } + + val connectionSettings = try { + CodeCatalystCredentialManager.getInstance().getConnectionSettings() ?: error("Unable to find connection settings") + } catch (e: ProcessCanceledException) { + return null + } + + val userId = lazilyGetUserId() + + val spaceName = connectionParams.space + val projectName = connectionParams.project + val envId = connectionParams.envId + val id = WorkspaceIdentifier(CawsProject(spaceName, projectName), envId) + + val lifetime = Lifetime.Eternal.createNested() + val workflowDisposable = Lifetime.Eternal.createNestedDisposable() + + return CawsGatewayConnectionHandle(lifetime, envId) { + // reference lost with all the blocks + it.let { gatewayHandle -> + val view = JBTabbedPane() + val workflowEmitter = TabbedWorkflowEmitter(view, workflowDisposable) + + fun handleException(e: Throwable) { + if (e is ProcessCanceledException || e is CancellationException) { + CodecatalystTelemetry.connect(project = null, userId = userId, result = TelemetryResult.Cancelled) + LOG.warn { "Connect to dev environment cancelled" } + } else { + CodecatalystTelemetry.connect(project = null, userId = userId, result = TelemetryResult.Failed, reason = e.javaClass.simpleName) + LOG.error(e) { "Caught exception while connecting to dev environment" } + } + lifetime.terminate() + } + + // TODO: Describe env to validate JB ide is set on it + lifetime.launch { + try { + val cawsClient = connectionSettings.awsClient() + val environmentActions = WorkspaceActions(spaceName, projectName, envId, cawsClient) + val executor = CawsCommandExecutor(cawsClient, envId, spaceName, projectName) + + // should probably consider logging output to logger as well + // on failure we should display meaningful error and put retry button somewhere + lifetime.startUnderModalProgressAsync( + title = message("caws.connecting.waiting_for_environment"), + canBeCancelled = true, + isIndeterminate = true, + ) { + val timeBeforeEnvIsRunningCheck = System.currentTimeMillis() + var validateEnvIsRunningResult = TelemetryResult.Succeeded + var errorMessageDuringStateValidation: String? = null + try { + validateEnvironmentIsRunning(indicator, environmentActions) + } catch (e: Exception) { + validateEnvIsRunningResult = TelemetryResult.Failed + errorMessageDuringStateValidation = e.message + throw e + } finally { + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = validateEnvIsRunningResult, + duration = (System.currentTimeMillis() - timeBeforeEnvIsRunningCheck).toDouble(), + codecatalystDevEnvironmentWorkflowStep = "validateEnvRunning", + codecatalystDevEnvironmentWorkflowError = errorMessageDuringStateValidation + ) + } + + scope.launch { + ApplicationManager.getApplication().messageBus.syncPublisher(WorkspaceNotifications.TOPIC) + .environmentStarted( + WorkspaceListStateChangeContext( + WorkspaceIdentifier(CawsProject(spaceName, projectName), envId) + ) + ) + } + + val pluginPath = "$IDE_BACKEND_DIR/plugins/${AwsToolkit.PLUGINS_INFO.getValue(AwsPlugin.TOOLKIT).path?.fileName}" + var retries = 3 + val startTimeToCheckInstallation = System.currentTimeMillis() + + val toolkitInstallSettings: ToolkitInstallSettings? = coroutineScope { + while (retries > 0) { + indicator.checkCanceled() + val pluginIsInstalled = executor.remoteDirectoryExists( + pluginPath, + timeout = Duration.ofSeconds(15) + ) + + when (pluginIsInstalled) { + null -> { + if (retries == 1) { + return@coroutineScope null + } else { + retries-- + continue + } + } + + true -> return@coroutineScope ToolkitInstallSettings.None + false -> return@coroutineScope connectionParams.toolkitInstallSettings + } + } + } as ToolkitInstallSettings? + + toolkitInstallSettings ?: let { + // environment is non-responsive to SSM; restart + LOG.warn { "Restarting $envId since it appears unresponsive to SSM Run-Command" } + val timeTakenToCheckInstallation = System.currentTimeMillis() - startTimeToCheckInstallation + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = TelemetryResult.Failed, + codecatalystDevEnvironmentWorkflowStep = "ToolkitInstallationSSMCheck", + codecatalystDevEnvironmentWorkflowError = "Timeout/Unknown error while connecting to Dev Env via SSM", + duration = timeTakenToCheckInstallation.toDouble() + ) + + scope.launch { + environmentActions.stopEnvironment() + GatewayUI.getInstance().connect(parameters) + } + + gatewayHandle.terminate() + return@startUnderModalProgressAsync JLabel() + } + + lifetime.startUnderBackgroundProgressAsync(message("caws.download.thin_client"), isIndeterminate = true) { + val (backendVersion, getBackendVersionTime) = measureTimedValue { + tryOrNull { + executor.executeCommandNonInteractive( + "sh", + "-c", + GET_IDE_BACKEND_VERSION_COMMAND, + timeout = Duration.ofSeconds(15) + ).stdout + } + } + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = if (backendVersion != null) TelemetryResult.Succeeded else TelemetryResult.Failed, + duration = getBackendVersionTime.toDouble(DurationUnit.MILLISECONDS), + codecatalystDevEnvironmentWorkflowStep = "getBackendVersion" + ) + + if (backendVersion.isNullOrBlank()) { + LOG.warn { "Could not determine backend version to prefetch thin client" } + } else { + val (clientPaths, downloadClientTime) = measureTimedValue { + BuildNumber.fromStringOrNull(backendVersion)?.asStringWithoutProductCode()?.let { build -> + LOG.info { "Fetching client for version: $build" } + CodeWithMeClientDownloader.downloadClientAndJdk(build, indicator) + } + } + + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = if (clientPaths != null) TelemetryResult.Succeeded else TelemetryResult.Failed, + duration = downloadClientTime.toDouble(DurationUnit.MILLISECONDS), + codecatalystDevEnvironmentWorkflowStep = "downloadThinClient" + ) + } + } + + runBackendWorkflow( + view, + workflowEmitter, + userId, + indicator, + lifetime.createNested(), + parameters, + executor, + id, + connectionParams.gitSettings, + toolkitInstallSettings + ).await() + }.invokeOnCompletion { e -> + if (e == null) { + CodecatalystTelemetry.connect(project = null, userId = userId, result = TelemetryResult.Succeeded) + lifetime.onTermination { + Disposer.dispose(workflowDisposable) + } + } else { + handleException(e) + if (e is ProcessCanceledException || e is CancellationException) { + return@invokeOnCompletion + } + runInEdt { + DialogBuilder().apply { + setCenterPanel( + panel { + row { + icon(AllIcons.General.ErrorDialog).align(AlignY.TOP) + + panel { + row { + label(message("caws.workspace.connection.failed")).applyToComponent { + font = JBFont.regular().asBold() + } + } + + row { + label(e.message ?: message("general.unknown_error")) + } + } + } + + if (view.tabCount != 0) { + collapsibleGroup(message("general.logs"), false) { + row { + cell(view) + .align(AlignX.FILL) + } + }.expanded = false + // TODO: can't seem to reliably force a terminal redraw on initial expand + } + } + ) + + addOkAction() + addCancelAction() + okAction.setText(message("settings.retry")) + setOkOperation { + dialogWrapper.close(DialogWrapper.OK_EXIT_CODE) + GatewayUI.getInstance().connect(parameters) + } + }.show() + Disposer.dispose(workflowDisposable) + } + } + } + } catch (e: Exception) { + handleException(e) + if (e is ProcessCanceledException || e is CancellationException) { + return@launch + } + + runInEdt { + Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message("caws.workspace.connection.failed")) + } + throw e + } + } + + return@let panel { + row { + cell(view) + .align(Align.FILL) + } + } + } + } + } + + private fun validateEnvironmentIsRunning( + indicator: ProgressIndicator, + environmentActions: WorkspaceActions, + ) { + when (val status = environmentActions.getEnvironmentDetails().status()) { + DevEnvironmentStatus.PENDING, DevEnvironmentStatus.STARTING -> environmentActions.waitForTaskReady(indicator) + DevEnvironmentStatus.RUNNING -> { + } + DevEnvironmentStatus.STOPPING -> { + environmentActions.waitForTaskStopped(indicator) + environmentActions.startEnvironment() + environmentActions.waitForTaskReady(indicator) + } + DevEnvironmentStatus.STOPPED -> { + environmentActions.startEnvironment() + environmentActions.waitForTaskReady(indicator) + } + DevEnvironmentStatus.DELETING, DevEnvironmentStatus.DELETED -> throw IllegalStateException("Environment is deleted, unable to start") + else -> throw IllegalStateException("Unknown state $status") + } + } + + private fun runBackendWorkflow( + view: JBTabbedPane, + workflowEmitter: TabbedWorkflowEmitter, + userId: String, + indicator: ProgressIndicator, + lifetime: LifetimeDefinition, + parameters: Map, + executor: CawsCommandExecutor, + envId: WorkspaceIdentifier, + gitSettings: GitSettings, + toolkitInstallSettings: ToolkitInstallSettings, + ): AsyncPromise { + val remoteScriptPath = "/tmp/${UUID.randomUUID()}" + val remoteProjectName = (gitSettings as? GitSettings.GitRepoSettings)?.repoName + + val steps = buildList { + add(CopyScripts(remoteScriptPath, executor)) + + when (gitSettings) { + is GitSettings.CloneGitSettings -> { + if (gitSettings.repo.scheme == "ssh") { + // TODO: we should probably use JB's SshConnectionService/ConnectionBuilder since they have better ssh agent support than we could write + add(PrimeSshAgent(gitSettings)) + } + add(CloneCode(remoteScriptPath, gitSettings, executor)) + } + + is GitSettings.CawsOwnedRepoSettings, + is GitSettings.NoRepo, + -> { + } + } + + when (toolkitInstallSettings) { + is ToolkitInstallSettings.None -> {} + is ToolkitInstallSettings.UseSelf -> { + add(installBundledPluginBackend(executor, remoteScriptPath, IDE_BACKEND_DIR)) + } + is ToolkitInstallSettings.UseArbitraryLocalPath -> { + add(InstallLocalPluginBackend(toolkitInstallSettings, executor, remoteScriptPath, IDE_BACKEND_DIR)) + } + is ToolkitInstallSettings.UseMarketPlace -> { + add(InstallMarketplacePluginBackend(null, executor, remoteScriptPath, IDE_BACKEND_DIR)) + } + } + + add(StartBackendV2(lifetime, indicator, envId, remoteProjectName)) + } + + val promise = AsyncPromise() + fun start() { + lifetime.launchOnUi { + view.removeAll() + } + + indicator.fraction = 0.0 + val workflow = object : StepWorkflow(steps) { + override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + runInEdt(ModalityState.any()) { + indicator.isIndeterminate = false + } + + topLevelSteps.forEachIndexed { i, step -> + indicator.checkCanceled() + runInEdt(ModalityState.any()) { + indicator.fraction = i.toDouble() / steps.size + indicator.text = step.stepName + } + + val start = System.currentTimeMillis() + var error: Throwable? = null + try { + step.run(context, stepEmitter) + } catch (e: Throwable) { + error = e + throw e + } finally { + val time = System.currentTimeMillis() - start + LOG.info { "${step.stepName} took ${time}ms" } + + val result = when (error) { + null -> TelemetryResult.Succeeded + is ProcessCanceledException, is CancellationException -> TelemetryResult.Cancelled + else -> TelemetryResult.Failed + } + + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = result, + duration = time.toDouble(), + codecatalystDevEnvironmentWorkflowStep = step.stepName, + codecatalystDevEnvironmentWorkflowError = error?.javaClass?.simpleName + ) + } + } + } + } + + StepExecutor(project = null, workflow, workflowEmitter) + .also { + it.addContext(CAWS_CONNECTION_PARAMETERS, parameters) + lifetime.onTermination { + it.getProcessHandler().destroyProcess() + } + + it.onSuccess = { + promise.setResult(Unit) + } + + it.onError = { throwable -> + promise.setError(throwable) + } + + it.startExecution() + } + } + + start() + + return promise + } +} + +data class CawsConnectionParameters( + val space: String, + val project: String, + val envId: String, + val gitSettings: GitSettings, + val toolkitInstallSettings: ToolkitInstallSettings, + val ssoSettings: SsoSettings?, +) { + companion object { + const val CAWS_SPACE = "aws.codecatalyst.space" + const val CAWS_PROJECT = "aws.codecatalyst.project" + const val CAWS_ENV_ID = "aws.codecatalyst.env.id" + const val CAWS_GIT_REPO_NAME = "aws.codecatalyst.git.repo.name" + const val CAWS_UNLINKED_GIT_REPO_URL = "aws.caws.unlinked.git.repo.url" + const val CAWS_UNLINKED_GIT_REPO_BRANCH = "aws.caws.unlinked.git.repo.branch" + const val DEV_SETTING_USE_BUNDLED_TOOLKIT = "aws.caws.dev.use.bundled.toolkit" + const val DEV_SETTING_TOOLKIT_PATH = "aws.caws.dev.toolkit.path" + const val DEV_SETTING_S3_STAGING = "aws.caws.dev.s3.staging" + const val SSO_START_URL = "sso_start_url" + const val SSO_REGION = "sso_region" + + fun fromParameters(parameters: Map): CawsConnectionParameters { + val spaceName = parameters[CAWS_SPACE] ?: error("Missing required parameter: CAWS space name") + val projectName = parameters[CAWS_PROJECT] ?: throw IllegalStateException("Missing required parameter: CAWS project name") + val envId = parameters[CAWS_ENV_ID] ?: throw IllegalStateException("Missing required parameter: CAWS environment id") + val repoName = parameters[CAWS_GIT_REPO_NAME] + val gitRepoUrl = parameters[CAWS_UNLINKED_GIT_REPO_URL] + val gitRepoBranch = parameters[CAWS_UNLINKED_GIT_REPO_BRANCH] + val useBundledToolkit = parameters[DEV_SETTING_USE_BUNDLED_TOOLKIT]?.toBoolean() + val toolkitPath = parameters[DEV_SETTING_TOOLKIT_PATH] + val s3StagingBucket = parameters[DEV_SETTING_S3_STAGING] + val ssoStartUrl = parameters[SSO_START_URL] + val ssoRegion = parameters[SSO_REGION] + + val gitSettings = + if (repoName != null) { + GitSettings.CawsOwnedRepoSettings(repoName) + } else if (!gitRepoUrl.isNullOrEmpty() && !gitRepoBranch.isNullOrEmpty()) { + GitSettings.CloneGitSettings(gitRepoUrl, gitRepoBranch) + } else { + GitSettings.NoRepo + } + + val providedInstallSettings = + if (useBundledToolkit == true) { + ToolkitInstallSettings.UseSelf + } else if (toolkitPath?.isNotBlank() == true && s3StagingBucket?.isNotBlank() == true) { + ToolkitInstallSettings.UseArbitraryLocalPath(toolkitPath, s3StagingBucket) + } else { + ToolkitInstallSettings.UseMarketPlace + } + + val ssoSettings = if (ssoStartUrl != null && ssoRegion != null) { + SsoSettings.fromUrlParameters(ssoStartUrl, ssoRegion) + } else { + null + } + + return CawsConnectionParameters( + spaceName, + projectName, + envId, + gitSettings, + providedInstallSettings, + ssoSettings + ) + } + } +} + +data class SsoSettings( + val startUrl: String, + val region: String, +) { + companion object { + fun fromUrlParameters(startUrl: String, region: String) = SsoSettings(URLDecoder.decode(startUrl, "UTF-8"), region) + } +} diff --git a/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt new file mode 100644 index 00000000000..ba7b92f3367 --- /dev/null +++ b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt @@ -0,0 +1,646 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.gateway + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.openapi.components.service +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.rd.createNestedDisposable +import com.intellij.openapi.rd.util.launchOnUi +import com.intellij.openapi.rd.util.startWithModalProgressAsync +import com.intellij.openapi.rd.util.withUiContext +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.setEmptyState +import com.intellij.openapi.util.Disposer +import com.intellij.platform.ide.progress.ModalTaskOwner +import com.intellij.platform.ide.progress.TaskCancellation +import com.intellij.platform.util.progress.indeterminateStep +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLoadingPanel +import com.intellij.ui.components.JBRadioButton +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.COLUMNS_MEDIUM +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.Row +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.bind +import com.intellij.ui.dsl.builder.bindItem +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.ui.dsl.builder.selected +import com.intellij.ui.dsl.builder.toMutableProperty +import com.intellij.ui.layout.not +import com.intellij.ui.layout.selected +import com.jetbrains.gateway.api.GatewayUI +import com.jetbrains.gateway.welcomeScreen.MultistagePanel +import com.jetbrains.gateway.welcomeScreen.MultistagePanelContainer +import com.jetbrains.gateway.welcomeScreen.MultistagePanelDelegate +import com.jetbrains.rd.util.lifetime.Lifetime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.amazon.awssdk.services.codecatalyst.model.InstanceType +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId +import software.aws.toolkits.jetbrains.gateway.connection.IdeBackendActions +import software.aws.toolkits.jetbrains.gateway.welcomescreen.recursivelySetBackground +import software.aws.toolkits.jetbrains.gateway.welcomescreen.setDefaultBackgroundAndBorder +import software.aws.toolkits.jetbrains.isDeveloperMode +import software.aws.toolkits.jetbrains.services.caws.CawsCodeRepository +import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints +import software.aws.toolkits.jetbrains.services.caws.CawsProject +import software.aws.toolkits.jetbrains.services.caws.CawsResources +import software.aws.toolkits.jetbrains.services.caws.InactivityTimeout +import software.aws.toolkits.jetbrains.services.caws.isSubscriptionFreeTier +import software.aws.toolkits.jetbrains.services.caws.isSupportedInFreeTier +import software.aws.toolkits.jetbrains.services.caws.listAccessibleProjectsPaginator +import software.aws.toolkits.jetbrains.services.caws.loadParameterDescriptions +import software.aws.toolkits.jetbrains.settings.CawsSpaceTracker +import software.aws.toolkits.jetbrains.ui.AsyncComboBox +import software.aws.toolkits.jetbrains.utils.ui.find +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodecatalystCreateDevEnvironmentRepoType +import software.aws.toolkits.telemetry.CodecatalystTelemetry +import java.awt.BorderLayout +import java.awt.event.ItemEvent +import javax.swing.JComponent +import software.aws.toolkits.telemetry.Result as TelemetryResult + +class CawsSettings( + // core bindings + var project: CawsProject? = null, + var productType: GatewayProduct? = null, + var linkedRepoName: String? = null, + var linkedRepoBranch: BranchSummary? = null, + var createBranchName: String = "", + var unlinkedRepoUrl: String = "", + var unlinkedRepoBranch: String? = null, + var alias: String = "", + var cloneType: CawsWizardCloneType = CawsWizardCloneType.NONE, + var instanceType: InstanceType = InstanceType.DEV_STANDARD1_SMALL, + var persistentStorage: Int? = 0, + var inactivityTimeout: InactivityTimeout = InactivityTimeout.DEFAULT_TIMEOUT, + + // dev settings + var useBundledToolkit: Boolean = false, + var s3StagingBucket: String = "", + var toolkitLocation: String = "", + + // intermediate values + var connectionSettings: ClientConnectionSettings<*>? = null, + var branchCloneType: BranchCloneType = BranchCloneType.EXISTING, + var is3P: Boolean = false, +) + +fun cawsWizard(lifetime: Lifetime, settings: CawsSettings = CawsSettings()) = MultistagePanelContainer( + listOf( + CawsInstanceSetupPanel(lifetime) + ), + settings, + object : MultistagePanelDelegate { + override fun onMultistagePanelBack(context: CawsSettings) { + GatewayUI.getInstance().reset() + CodecatalystTelemetry.createDevEnvironment(project = null, userId = lazilyGetUserId(), result = TelemetryResult.Cancelled) + } + + override fun onMultistagePanelDone(context: CawsSettings) { + val productType = context.productType ?: throw RuntimeException("CAWS wizard finished but productType was not set") + val connectionSettings = context.connectionSettings ?: throw RuntimeException("CAWS wizard finished but connectionSettings was not set") + + lifetime.startWithModalProgressAsync( + owner = ModalTaskOwner.guess(), + title = message("caws.creating_workspace"), + cancellation = TaskCancellation.nonCancellable() + ) { + val userId = lazilyGetUserId() + val start = System.currentTimeMillis() + val env = try { + val cawsClient = connectionSettings.awsClient() + if (context.cloneType == CawsWizardCloneType.UNLINKED_3P) { + error("Not implemented") + } + + if (context.is3P) { + context.branchCloneType = BranchCloneType.EXISTING + } + + if (context.branchCloneType == BranchCloneType.NEW_FROM_EXISTING) { + indeterminateStep(message("caws.creating_branch")) { + cawsClient.createSourceRepositoryBranch { + val project = context.project ?: throw RuntimeException("project was null") + val commitId = context.linkedRepoBranch?.headCommitId ?: throw RuntimeException("source commit id was not defined") + it.spaceName(project.space) + it.projectName(project.project) + it.sourceRepositoryName(context.linkedRepoName) + it.name(context.createBranchName) + it.headCommitId(commitId) + } + } + } + + IdeBackendActions.createWorkspace(cawsClient, context).also { + val repoType = when (context.cloneType) { + CawsWizardCloneType.CAWS -> CodecatalystCreateDevEnvironmentRepoType.Linked + CawsWizardCloneType.UNLINKED_3P -> CodecatalystCreateDevEnvironmentRepoType.Unlinked + CawsWizardCloneType.NONE -> CodecatalystCreateDevEnvironmentRepoType.None + } + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = TelemetryResult.Succeeded, + duration = (System.currentTimeMillis() - start).toDouble(), + codecatalystDevEnvironmentWorkflowStep = "createDevEnvironment" + ) + CodecatalystTelemetry.createDevEnvironment( + project = null, + userId = userId, + codecatalystCreateDevEnvironmentRepoType = repoType, + result = TelemetryResult.Succeeded + ) + } + } catch (e: Exception) { + val message = message("caws.workspace.creation.failed") + getLogger().error(e) { message } + withUiContext { + Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message) + } + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = TelemetryResult.Failed, + duration = (System.currentTimeMillis() - start).toDouble(), + codecatalystDevEnvironmentWorkflowStep = "createDevEnvironment" + ) + CodecatalystTelemetry.createDevEnvironment(project = null, userId = userId, result = TelemetryResult.Failed) + return@startWithModalProgressAsync + } + + val currentConnection = service() + .activeConnectionForFeature(CodeCatalystConnection.getInstance()) as AwsBearerTokenConnection? + ?: error("Connection cannot be null") + + val parameters = mapOf( + CawsConnectionParameters.CAWS_SPACE to env.identifier.project.space, + CawsConnectionParameters.CAWS_PROJECT to env.identifier.project.project, + CawsConnectionParameters.CAWS_ENV_ID to env.identifier.id, + CawsConnectionParameters.DEV_SETTING_USE_BUNDLED_TOOLKIT to context.useBundledToolkit.toString(), + CawsConnectionParameters.DEV_SETTING_S3_STAGING to context.s3StagingBucket, + CawsConnectionParameters.DEV_SETTING_TOOLKIT_PATH to context.toolkitLocation, + CawsConnectionParameters.SSO_START_URL to currentConnection.startUrl, + CawsConnectionParameters.SSO_REGION to currentConnection.region + ) + buildMap { + when (context.cloneType) { + CawsWizardCloneType.CAWS -> { + val repoName = context.linkedRepoName ?: throw RuntimeException("CAWS wizard finished but linkedRepoName was not set") + put(CawsConnectionParameters.CAWS_GIT_REPO_NAME, repoName) + } + + CawsWizardCloneType.UNLINKED_3P -> { + val branch = context.unlinkedRepoBranch ?: throw RuntimeException("CAWS wizard finished but unlinkedRepoBranch was not set") + put(CawsConnectionParameters.CAWS_UNLINKED_GIT_REPO_URL, context.unlinkedRepoUrl) + put(CawsConnectionParameters.CAWS_UNLINKED_GIT_REPO_BRANCH, branch) + } + + CawsWizardCloneType.NONE -> {} + } + } + + withUiContext { + GatewayUI.getInstance().connect(parameters) + } + } + } + } +) + +class CawsInstanceSetupPanel(private val lifetime: Lifetime) : MultistagePanel { + private lateinit var panel: EnvironmentDetailsPanel + + override fun getComponent(context: CawsSettings): JComponent { + panel = EnvironmentDetailsPanel(context, lifetime) + return panel.getComponent() + } + + override fun init(context: CawsSettings, canGoBackAndForthConsumer: (Boolean, Boolean) -> Unit) { + } + + override fun onEnter(context: CawsSettings, isForward: Boolean) {} + + override suspend fun onGoingToLeave(context: CawsSettings, isForward: Boolean): Boolean { + if (isForward) { + return panel.runValidation() + } + + return true + } + + override fun onLeave(context: CawsSettings, isForward: Boolean) {} + + override fun shouldSkip(context: CawsSettings, isForward: Boolean) = false + + override fun forwardButtonText(): String = message("caws.create_workspace") +} + +class EnvironmentDetailsPanel(private val context: CawsSettings, lifetime: Lifetime) : CawsLoadingPanel(lifetime) { + private val disposable = lifetime.createNestedDisposable() + private val environmentParameters = loadParameterDescriptions().environmentParameters + private lateinit var createPanel: DialogPanel + + override val title = context.project?.let { message("caws.workspace.details.project_specific_title", it.project) } + ?: message("caws.workspace.details.title") + + override fun getContent(connectionSettings: ClientConnectionSettings<*>): JComponent { + context.connectionSettings = connectionSettings + val client = AwsClientManager.getInstance().getClient(connectionSettings) + val spaces = getSpaces(client) + return if (spaces.isEmpty()) { + infoPanel() + .addLine(message("caws.workspace.details.introduction_message")) + .addAction(message("general.get_started")) { + BrowserLauncher.instance.browse(CawsEndpoints.ConsoleFactory.baseUrl()) + } + .addAction(message("general.refresh")) { lifetime.launchOnUi { startLoading() } } + } else { + panel { + row(message("caws.workspace.ide_label")) { + bottomGap(BottomGap.MEDIUM) + ideVersionComboBox(disposable, context::productType) + } + + lateinit var branchOptions: Row + lateinit var newBranchOption: Cell + lateinit var newBranch: Row + lateinit var cloneRepoButton: Cell + val existingProject = context.project + val existingRepo = context.linkedRepoName + + if (existingRepo != null) { + context.cloneType = CawsWizardCloneType.CAWS + } + + panel { + group(message("caws.workspace.settings.repository_header"), indent = false) { + row { + comment(message("caws.workspace.clone.info_repo")) + } + + buttonsGroup { + row { + cloneRepoButton = radioButton(message("caws.workspace.details.clone_repo"), CawsWizardCloneType.CAWS).applyToComponent { + isSelected = context.cloneType == CawsWizardCloneType.CAWS + } + + radioButton(message("caws.workspace.details.create_empty_dev_env"), CawsWizardCloneType.NONE).applyToComponent { + isSelected = context.cloneType == CawsWizardCloneType.NONE + } + } + }.bind({ context.cloneType }, { context.cloneType = it }) + + row { + label(message("caws.workspace.clone.info")) + }.visibleIf(cloneRepoButton.selected) + + val projectCombo = AsyncComboBox { label, value, _ -> + value ?: return@AsyncComboBox + label.text = "${value.project} (${value.space})" + } + Disposer.register(disposable, projectCombo) + + row(message("caws.project")) { + cell(projectCombo) + .bindItem(context::project.toMutableProperty()) + .errorOnApply(message("caws.workspace.details.project_validation")) { it.selectedItem == null } + .columns(COLUMNS_MEDIUM) + } + + val linkedRepoCombo = AsyncComboBox { label, value, _ -> label.text = value?.name } + val linkedBranchCombo = AsyncComboBox { label, value, _ -> label.text = value?.name } + Disposer.register(disposable, linkedRepoCombo) + Disposer.register(disposable, linkedBranchCombo) + + row(message("caws.repository")) { + cell(linkedRepoCombo) + .bind( + { it.selected()?.name }, + { i, v -> i.selectedItem = i.model.find { it.name == v } }, + context::linkedRepoName.toMutableProperty() + ) + .errorOnApply(message("caws.workspace.details.repository_validation")) { it.isVisible && it.selectedItem == null } + .columns(COLUMNS_MEDIUM) + projectCombo.addActionListener { + linkedRepoCombo.proposeModelUpdate { model -> + projectCombo.selected()?.let { project -> + val repositories = getRepoNames(project, client) + repositories.forEach { model.addElement(it) } + } + } + } + }.visibleIf(cloneRepoButton.selected) + + if (!existingRepo.isNullOrEmpty()) { + linkedBranchCombo.proposeModelUpdate { model -> + val project = existingProject ?: throw RuntimeException("existingProject was null after null check") + getBranchNames(project, existingRepo, client).forEach { model.addElement(it) } + } + } + + panel { + row { + label(message("caws.workspace.details.branch_title")) + } + + row { comment(message("caws.workspace.details.create_branch_comment")) } + + buttonsGroup { + branchOptions = row { + newBranchOption = radioButton(message("caws.workspace.details.branch_new"), BranchCloneType.NEW_FROM_EXISTING) + .applyToComponent { + isSelected = context.branchCloneType == BranchCloneType.NEW_FROM_EXISTING + }.bindSelected( + { context.branchCloneType == BranchCloneType.NEW_FROM_EXISTING }, + { if (it) context.branchCloneType = BranchCloneType.NEW_FROM_EXISTING } + ) + + radioButton(message("caws.workspace.details.branch_existing"), BranchCloneType.EXISTING) + .applyToComponent { + isSelected = context.branchCloneType == BranchCloneType.EXISTING + }.bindSelected( + { context.branchCloneType == BranchCloneType.EXISTING }, + { if (it) context.branchCloneType = BranchCloneType.EXISTING } + ) + }.apply { visible(cloneRepoButton.component.isSelected) } + }.bind({ context.branchCloneType }, { context.branchCloneType = it }) + + newBranch = row(message("caws.workspace.details.branch_new")) { + textField().bindText(context::createBranchName) + .errorOnApply(message("caws.workspace.details.branch_new_validation")) { + it.isVisible && it.text.isNullOrBlank() + } + }.visibleIf(newBranchOption.selected) + + row(message("caws.workspace.details.branch_existing")) { + cell(linkedBranchCombo) + .bindItem(context::linkedRepoBranch.toMutableProperty()) + .errorOnApply(message("caws.workspace.details.branch_validation")) { it.isVisible && it.selectedItem == null } + .columns(COLUMNS_MEDIUM) + + linkedRepoCombo.addActionListener { + linkedBranchCombo.proposeModelUpdate { model -> + projectCombo.selected()?.let { project -> + linkedRepoCombo.selected()?.let { repo -> + // janky nonsense because there's no good way to model this though the component predicate system + context.is3P = isRepo3P(project, repo.name) + branchOptions.visible(!context.is3P) + + val branches = getBranchNames(project, repo.name, client) + branches.forEach { model.addElement(it) } + } + } + } + } + contextHelp(message("caws.one.branch.per.dev.env.comment")) + } + }.visibleIf(cloneRepoButton.selected) + + // need here to force comboboxes to load + getProjects(client, spaces).apply { + forEach { projectCombo.addItem(it) } + projectCombo.selectedItem = existingProject + ?: firstOrNull { it.space == CawsSpaceTracker.getInstance().lastSpaceName() } + ?: firstOrNull() + } + + val propertyGraph = PropertyGraph() + val projectProperty = propertyGraph.property(projectCombo.selected()) + projectCombo.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + projectProperty.set(it.item as CawsProject?) + } + } + + row(message("caws.workspace.details.alias.label")) { + topGap(TopGap.MEDIUM) + // TODO: would be nice to have mutable combobox with existing projects + textField() + .bindText(context::alias) + .columns(COLUMNS_MEDIUM) + .applyToComponent { + setEmptyState(message("general.optional")) + } + }.contextHelp(message("caws.alias.instruction.text")) + + row { + placeholder() + }.bottomGap(BottomGap.MEDIUM) + + group(message("caws.workspace.settings"), indent = false) { + row { + val wrapper = Wrapper().apply { isOpaque = false } + val loadingPanel = JBLoadingPanel(BorderLayout(), disposable).apply { + add(wrapper, BorderLayout.CENTER) + } + val content = { space: String? -> + envConfigPanel(space?.let { isSubscriptionFreeTier(client, it) } ?: false) + } + + wrapper.setContent(content(projectProperty.get()?.space)) + + val getDialogPanel = { wrapper.targetComponent as DialogPanel } + cell(loadingPanel) + .onApply { getDialogPanel().apply() } + .onReset { getDialogPanel().reset() } + .onIsModified { getDialogPanel().isModified() } + + projectProperty.afterChange { project -> + lifetime.launchOnUi { + loadingPanel.startLoading() + var panel: JComponent? = null + CoroutineScope(getCoroutineBgContext() + SupervisorJob()).launch { + panel = content(project?.space) + } + panel?.let { wrapper.setContent(it) } + loadingPanel.stopLoading() + } + } + } + } + + if (isDeveloperMode()) { + group(message("caws.workspace.details.developer_tool_settings")) { + lateinit var useBundledToolkit: Cell + row { + useBundledToolkit = checkBox(message("caws.workspace.details.use_bundled_toolkit")).bindSelected(context::useBundledToolkit) + } + + panel { + row(message("caws.workspace.details.backend_toolkit_location")) { + textFieldWithBrowseButton( + fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFileDescriptor().withTitle( + message("caws.workspace.details.toolkit_location") + ) + ).bindText(context::toolkitLocation) + } + + row(message("caws.workspace.details.s3_bucket")) { + textField() + .bindText(context::s3StagingBucket) + .columns(COLUMNS_MEDIUM) + } + }.visibleIf(useBundledToolkit.selected.not()) + } + } + } + } + }.also { + setDefaultBackgroundAndBorder(it) + it.registerValidators(disposable) + createPanel = it + }.let { + ScrollPaneFactory.createScrollPane(it, true) + } + } + } + + private fun getSpaces(client: CodeCatalystClient) = client.listSpacesPaginator { } + .items() + .map { it.name() } + + private fun getProjects(client: CodeCatalystClient, spaces: List) = spaces + .flatMap { space -> + client.listAccessibleProjectsPaginator { it.spaceName(space) }.items() + .map { project -> CawsProject(space, project.name()) } + } + .sortedByDescending { it.project } + + private fun getRepoNames(project: CawsProject, client: CodeCatalystClient) = client.listSourceRepositoriesPaginator { + it.spaceName(project.space) + it.projectName(project.project) + } + .items() + .map { it.toSourceRepository() } + .sortedBy { it.name } + + private fun isRepo3P(project: CawsProject, repo: String): Boolean { + val connectionSettings = context.connectionSettings ?: throw RuntimeException("ConnectionSettings was not set") + val url = AwsResourceCache.getInstance().getResource( + CawsResources.cloneUrls(CawsCodeRepository(project.space, project.project, repo)), + connectionSettings + ).toCompletableFuture().get() + return !CawsEndpoints.isCawsGit(url) + } + + private fun getBranchNames(project: CawsProject, repo: String, client: CodeCatalystClient) = + client.listSourceRepositoryBranchesPaginator { + it.spaceName(project.space) + it.projectName(project.project) + it.sourceRepositoryName(repo) + } + .items() + .map { summary -> + val branchName = summary.name() + + BranchSummary( + if (branchName.startsWith(BRANCH_PREFIX)) { + branchName.substringAfter(BRANCH_PREFIX) + } else { + branchName + }, + summary.headCommitId() + ) + } + .sortedBy { it.name } + + private fun envConfigPanel(isFreeTier: Boolean) = + panel { + if (isFreeTier) { + row { + comment(message("caws.compute.size.in.free.tier.comment")) + } + } + + cawsEnvironmentSize( + environmentParameters, + context::instanceType, + isFreeTier + ) + + row { + label(message("caws.workspace.details.persistent_storage_title")) + comboBox( + PersistentStorageOptions(environmentParameters.persistentStorageSize.filter { it > 0 }, isFreeTier), + SimpleListCellRenderer.create { label, value, _ -> + label.isEnabled = if (isFreeTier) { + value.isSupportedInFreeTier() + } else { + true + } + label.text = message("caws.storage.value", value) + } + ).bindItem(context::persistentStorage.toMutableProperty()) + }.bottomGap(BottomGap.MEDIUM).contextHelp(message("caws.workspace.details.persistent_storage_comment")) + + row { + cawsEnvironmentTimeout(context::inactivityTimeout) + }.contextHelp(message("caws.workspace.details.inactivity_timeout_comment")) + }.apply { + recursivelySetBackground(this) + } + + fun runValidation(): Boolean { + try { + if (createPanel.validateAll().isEmpty()) { + createPanel.apply() + return true + } + } catch (e: UninitializedPropertyAccessException) { // error is displayed on the panel + } + return false + } + + companion object { + const val BRANCH_PREFIX = "refs/heads/" + } +} + +enum class CawsWizardCloneType { + CAWS, + UNLINKED_3P, + NONE, +} + +enum class BranchCloneType { + EXISTING, + NEW_FROM_EXISTING, +} + +class PersistentStorageOptions(items: List, private val subscriptionIsFreeTier: Boolean) : CollectionComboBoxModel(items) { + override fun setSelectedItem(item: Any?) { + if (subscriptionIsFreeTier) { + if (item != 16) { + super.setSelectedItem(16) + } + } else { + super.setSelectedItem(item) + } + } +} From 75ff54ba3f95bbd4be124a64a45a905ce37e15aa Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Wed, 12 Nov 2025 19:34:15 -0800 Subject: [PATCH 12/35] Version segregate jetbrains-core test files for 2025.3 JUnit5 ProjectExtension removed in 2025.3. Migrate to HeavyPlatformTestCase (JUnit4) for affected tests. Files segregated: - AwsToolkitExplorerToolWindowTest.kt - GettingStartedOnStartupTest.kt - JavaTestUtils.kt Structure: - tst-242-252/: Original JUnit5 tests for 2024.2-2025.2 - tst-253+/: Updated JUnit4 tests for 2025.3+ --- .../AwsToolkitExplorerToolWindowTest.kt | 0 .../GettingStartedOnStartupTest.kt | 0 .../toolkits/jetbrains/utils/JavaTestUtils.kt | 0 .../AwsToolkitExplorerToolWindowTest.kt | 65 ++++ .../GettingStartedOnStartupTest.kt | 90 ++++++ .../toolkits/jetbrains/utils/JavaTestUtils.kt | 284 ++++++++++++++++++ 6 files changed, 439 insertions(+) rename plugins/toolkit/jetbrains-core/{tst => tst-242-252}/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt (100%) rename plugins/toolkit/jetbrains-core/{tst => tst-242-252}/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt (100%) rename plugins/toolkit/jetbrains-core/{tst => tst-242-252}/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt (100%) create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt b/plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt similarity index 100% rename from plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt rename to plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt b/plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt similarity index 100% rename from plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt rename to plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt b/plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt similarity index 100% rename from plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt rename to plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt new file mode 100644 index 00000000000..dd091f68bc9 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt @@ -0,0 +1,65 @@ +// Copyright 2023 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.application.runInEdt +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.runInEdtAndGet +import org.assertj.core.api.Assertions.assertThat +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.ToolWindowHeadlessManagerImpl + +class AwsToolkitExplorerToolWindowTest : HeavyPlatformTestCase() { + + + fun `test save current tab state`() { + (ToolWindowManager.getInstance(project) as ToolWindowHeadlessManagerImpl) + .doRegisterToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) + val sut = runInEdtAndGet { AwsToolkitExplorerToolWindow(project) } + + runInEdt { + sut.selectTab(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID) + + sut.selectTab(AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID) + } + } + + fun `test load tab state`() { + (ToolWindowManager.getInstance(project) as ToolWindowHeadlessManagerImpl) + .doRegisterToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) + val sut = runInEdtAndGet { AwsToolkitExplorerToolWindow(project) } + runInEdt { + sut.loadState( + AwsToolkitExplorerToolWindowState().apply { + selectedTab = + AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID + } + ) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.Q_TAB_ID) + + sut.loadState( + AwsToolkitExplorerToolWindowState().apply { + selectedTab = + AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID + } + ) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.Q_TAB_ID) + } + } + + fun `test handles loading invalid state`() { + (ToolWindowManager.getInstance(project) as ToolWindowHeadlessManagerImpl) + .doRegisterToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) + val sut = runInEdtAndGet { AwsToolkitExplorerToolWindow(project) } + + sut.loadState( + AwsToolkitExplorerToolWindowState().apply { + selectedTab = aString() + } + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt new file mode 100644 index 00000000000..10331c256c1 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt @@ -0,0 +1,90 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.configurationStore.getPersistentStateComponentStorageLocation +import com.intellij.testFramework.HeavyPlatformTestCase +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.touch +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerExtension +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel +import software.aws.toolkits.jetbrains.settings.GettingStartedSettings + +@ExperimentalCoroutinesApi +class GettingStartedOnStartupTest : HeavyPlatformTestCase() { + private val credManagerExtension = MockCredentialManagerExtension() + private val sut = GettingStartedOnStartup() + + override fun tearDown() { + try { + GettingStartedSettings.getInstance().shouldDisplayPage = true + getPersistentStateComponentStorageLocation(GettingStartedSettings::class.java)?.deleteIfExists() + } finally { + super.tearDown() + } + } + + fun `test does not show screen if aws settings exist and has credentials`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + val fp = getPersistentStateComponentStorageLocation(GettingStartedSettings::class.java) ?: error( + "could not determine persistent storage for GettingStartedSettings" + ) + try { + fp.touch() + sut.runActivity(project) + } finally { + fp.deleteIfExists() + } + + verify(exactly = 0) { + GettingStartedPanel.openPanel(project) + } + } + + fun `test does not show screen if has previously shown screen`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + GettingStartedSettings.getInstance().shouldDisplayPage = false + sut.runActivity(project) + + verify(exactly = 0) { + GettingStartedPanel.openPanel(project) + } + } + + fun `test shows screen if aws settings exist and no credentials`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + credManagerExtension.clear() + val fp = getPersistentStateComponentStorageLocation(GettingStartedSettings::class.java) ?: error( + "could not determine persistent storage for GettingStartedSettings" + ) + try { + fp.touch() + sut.runActivity(project) + } finally { + fp.deleteIfExists() + } + + verify { + GettingStartedPanel.openPanel(project, any(), any()) + } + } + + fun `test shows screen on first install`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + sut.runActivity(project) + + verify { + GettingStartedPanel.openPanel(project, any(), any()) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt new file mode 100644 index 00000000000..b11e0cfee45 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt @@ -0,0 +1,284 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils + +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.application.runWriteActionAndWait +import com.intellij.openapi.externalSystem.importing.ImportSpecBuilder +import com.intellij.openapi.externalSystem.model.DataNode +import com.intellij.openapi.externalSystem.model.project.ProjectData +import com.intellij.openapi.externalSystem.service.execution.ProgressExecutionMode +import com.intellij.openapi.externalSystem.service.project.ExternalProjectRefreshCallback +import com.intellij.openapi.externalSystem.service.project.ProjectDataManager +import com.intellij.openapi.externalSystem.settings.ExternalSystemSettingsListener +import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil +import com.intellij.openapi.externalSystem.util.ExternalSystemUtil +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.projectRoots.ProjectJdkTable +import com.intellij.openapi.projectRoots.impl.JavaAwareProjectJdkTableImpl +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.projectRoots.impl.SdkVersionUtil +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Ref +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiJavaFile +import com.intellij.testFramework.IdeaTestUtil +import com.intellij.testFramework.RunAll +import com.intellij.testFramework.runInEdtAndWait +import com.intellij.xdebugger.XDebuggerUtil +import org.jetbrains.idea.maven.model.MavenExplicitProfiles +import org.jetbrains.idea.maven.project.MavenProjectsManager +import org.jetbrains.idea.maven.server.MavenServerManager +import org.jetbrains.idea.maven.utils.MavenProgressIndicator.MavenProgressTracker +import org.jetbrains.plugins.gradle.jvmcompat.GradleJvmSupportMatrix +import org.jetbrains.plugins.gradle.settings.GradleProjectSettings +import org.jetbrains.plugins.gradle.util.GradleConstants +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.inputStream +import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.rules.addFileToModule +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.isDirectory + +fun HeavyJavaCodeInsightTestFixtureRule.setUpJdk(jdkName: String = "Real JDK"): String { + // attempt to find a JDK that works for gradle + val jdkHome = JavaSdk.getInstance().suggestHomePaths().firstOrNull { + val version = SdkVersionUtil.getJdkVersionInfo(it)?.version ?: return@firstOrNull false + version < GradleJvmSupportMatrix.getAllSupportedJavaVersionsByIdea().max() + } ?: IdeaTestUtil.requireRealJdkHome() + println("Using $jdkHome as JDK home") + + runInEdtAndWait { + runWriteAction { + VfsRootAccess.allowRootAccess(this.fixture.testRootDisposable, jdkHome) + val jdkHomeDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(jdkHome)!! + val jdk = SdkConfigurationUtil.setupSdk(emptyArray(), jdkHomeDir, JavaSdk.getInstance(), false, null, jdkName)!! + + ProjectJdkTable.getInstance().addJdk(jdk, this.fixture.testRootDisposable) + ModuleRootModificationUtil.setModuleSdk(this.module, jdk) + } + } + + return jdkHome +} + +fun HeavyJavaCodeInsightTestFixtureRule.setUpGradleProject(compatibility: String = "1.8"): PsiClass { + val fixture = this.fixture + val buildFile = fixture.addFileToModule( + this.module, + "build.gradle", + """ + plugins { + id 'java' + } + + sourceCompatibility = '$compatibility' + targetCompatibility = '$compatibility' + """.trimIndent() + ).virtualFile + + // Use our project's own Gradle version + this.copyGradleFiles() + + val lambdaClass = fixture.addClass( + """ + package com.example; + + public class SomeClass { + public static String upperCase(String input) { + return input.toUpperCase(); + } + } + """.trimIndent() + ) + + val jdkName = "Gradle JDK" + setUpJdk(jdkName) + + ExternalSystemApiUtil.subscribe( + project, + GradleConstants.SYSTEM_ID, + object : ExternalSystemSettingsListener { + override fun onProjectsLinked(settings: Collection) { + super.onProjectsLinked(settings) + settings.first().gradleJvm = jdkName + } + } + ) + + val gradleProjectSettings = GradleProjectSettings().apply { + withQualifiedModuleNames() + externalProjectPath = buildFile.path + } + + val externalSystemSettings = ExternalSystemApiUtil.getSettings(project, GradleConstants.SYSTEM_ID) + externalSystemSettings.setLinkedProjectsSettings(setOf(gradleProjectSettings)) + + val error = Ref.create() + + val refreshCallback = object : ExternalProjectRefreshCallback { + override fun onSuccess(externalProject: DataNode?) { + if (externalProject == null) { + System.err.println("Got null External project after import") + return + } + ProjectDataManager.getInstance().importData(externalProject, project, true) + println("External project was successfully imported") + } + + override fun onFailure(errorMessage: String, errorDetails: String?) { + error.set(errorMessage) + } + } + + val importSpecBuilder = ImportSpecBuilder(project, GradleConstants.SYSTEM_ID) + .callback(refreshCallback) + .use(ProgressExecutionMode.MODAL_SYNC) + + ExternalSystemUtil.refreshProjects(importSpecBuilder) + + if (!error.isNull) { + error("Import failed: " + error.get()) + } + + return lambdaClass +} + +fun HeavyJavaCodeInsightTestFixtureRule.addBreakpoint() { + runInEdtAndWait { + val document = fixture.editor.document + val psiFile = fixture.file as PsiJavaFile + val body = psiFile.classes[0].allMethods[0].body!!.statements[0] + val lineNumber = document.getLineNumber(body.textOffset) + + XDebuggerUtil.getInstance().toggleLineBreakpoint( + project, + fixture.file.virtualFile, + lineNumber + ) + } +} + +private fun HeavyJavaCodeInsightTestFixtureRule.copyGradleFiles() { + val gradleRoot = findGradlew() + + // annoying and can't repro locally + val gradleWrapperHome = Paths.get(System.getProperty("user.home"), ".gradle", "wrapper").toRealPath() + if (gradleWrapperHome.exists()) { + println("Allowing vfs access to $gradleWrapperHome") + VfsRootAccess.allowRootAccess(this.fixture.testRootDisposable, gradleWrapperHome.toString()) + } + + val gradleFiles = setOf("gradle/wrapper", "gradlew.bat", "gradlew") + + gradleFiles.forEach { + val gradleFile = gradleRoot.resolve(it) + if (gradleFile.exists()) { + copyPath(gradleRoot, gradleFile) + } else { + throw IllegalStateException("Failed to locate $it") + } + } +} + +private fun HeavyJavaCodeInsightTestFixtureRule.copyPath(root: Path, path: Path) { + if (path.isDirectory()) { + Files.list(path).forEach { + // Skip over files like .DS_Store. No gradlew related files start with a "." so safe to skip + if (it.fileName.toString().startsWith(".")) { + return@forEach + } + this@copyPath.copyPath(root, it) + } + } else { + fixture.addFileToModule(module, root.relativize(path).toString(), "").also { newFile -> + runInEdtAndWait { + runWriteAction { + newFile.virtualFile.getOutputStream(null).use { out -> + path.inputStream().use { it.copyTo(out) } + } + } + } + if (SystemInfo.isUnix) { + val newPath = Paths.get(newFile.virtualFile.path) + Files.setPosixFilePermissions(newPath, Files.getPosixFilePermissions(path)) + } + } + } +} + +private fun findGradlew(): Path { + var root = Paths.get("").toAbsolutePath() + while (root.parent != null) { + if (root.resolve("gradlew").exists()) { + return root + } else { + root = root.parent + } + } + + throw IllegalStateException("Failed to locate gradlew") +} + +internal suspend fun HeavyJavaCodeInsightTestFixtureRule.setUpMavenProject(): PsiClass { + val fixture = this.fixture + val pomFile = fixture.addFileToModule( + this.module, + "pom.xml", + """ + + 4.0.0 + helloworld + HelloWorld + 1.0 + jar + A sample Hello World created for SAM CLI. + + 1.8 + 1.8 + + + """.trimIndent() + ).virtualFile + + val lambdaClass = fixture.addClass( + """ + package com.example; + + public class SomeClass { + public static String upperCase(String input) { + return input.toUpperCase(); + } + } + """.trimIndent() + ) + + Disposer.register(this.fixture.testRootDisposable) { + RunAll.runAll( + { runWriteActionAndWait { JavaAwareProjectJdkTableImpl.removeInternalJdkInTests() } }, + // unsure why we can't let connectors be closed automatically during disposer cleanup + { Disposer.dispose(MavenServerManager.getInstance()) } + ) + } + + val projectsManager = MavenProjectsManager.getInstance(project) + projectsManager.initForTests() + + val poms = listOf(pomFile) + projectsManager.addManagedFilesWithProfiles(poms, MavenExplicitProfiles.NONE, null, null, true) + + runInEdtAndWait { + project.getServiceIfCreated(MavenProgressTracker::class.java)?.waitForProgressCompletion() + // importProjects() removed in 2025.3 - project import now handled automatically by test framework + // projectsManager.importProjects() + } + return lambdaClass +} From e4786404cb9137e50e8efffbd15780f0578d5ea1 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Wed, 12 Nov 2025 20:04:07 -0800 Subject: [PATCH 13/35] Version segregate jetbrains-community test files for 2025.3 Migrate ProjectExtension tests to HeavyPlatformTestCase for 2025.3. Files segregated: - BrowserMessageTest.kt - LoginBrowserTest.kt - DefaultToolkitAuthManagerTest.kt - IdcRolePopupTest.kt - SetupAuthenticationDialogTest.kt Structure: - tst-242-252/: JUnit5 with ProjectExtension (2024.2-2025.2) - tst-253+/: JUnit4 with HeavyPlatformTestCase (2025.3+) --- .../jetbrains/core/BrowserMessageTest.kt | 0 .../jetbrains/core/LoginBrowserTest.kt | 0 .../DefaultToolkitAuthManagerTest.kt | 0 .../core/gettingstarted/IdcRolePopupTest.kt | 0 .../SetupAuthenticationDialogTest.kt | 0 .../jetbrains/core/BrowserMessageTest.kt | 334 +++++++++++++ .../jetbrains/core/LoginBrowserTest.kt | 145 ++++++ .../DefaultToolkitAuthManagerTest.kt | 456 ++++++++++++++++++ .../core/gettingstarted/IdcRolePopupTest.kt | 102 ++++ .../SetupAuthenticationDialogTest.kt | 340 +++++++++++++ 10 files changed, 1377 insertions(+) rename plugins/core/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt (100%) rename plugins/core/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt (100%) rename plugins/core/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt (100%) rename plugins/core/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt (100%) rename plugins/core/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt (100%) create mode 100644 plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt create mode 100644 plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt create mode 100644 plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt create mode 100644 plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt create mode 100644 plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt new file mode 100644 index 00000000000..7148c3c70f2 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt @@ -0,0 +1,334 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.project.Project +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.assertj.core.api.ObjectAssert +import org.junit.jupiter.api.assertDoesNotThrow +import org.mockito.kotlin.mock +import software.aws.toolkits.jetbrains.core.webview.BrowserMessage +import software.aws.toolkits.jetbrains.core.webview.BrowserState +import software.aws.toolkits.jetbrains.core.webview.LoginBrowser + +class NoOpLoginBrowser(project: Project) : LoginBrowser(project) { + override val jcefBrowser: JBCefBrowserBase = mock() + + override fun prepareBrowser(state: BrowserState) {} + + override fun loadWebView(query: JBCefJSQuery) {} + + override fun handleBrowserMessage(message: BrowserMessage?) {} +} + +class BrowserMessageTest : HeavyPlatformTestCase() { + private lateinit var objectMapper: ObjectMapper + + + + private inline fun assertDeserializedInstanceOf(jsonStr: String): ObjectAssert { + val actual = objectMapper.readValue(jsonStr) + return assertThat(actual).isInstanceOf(T::class.java) + } + + private inline fun assertDeserializedWillThrow(jsonStr: String) { + assertThatThrownBy { + objectMapper.readValue(jsonStr) + }.isInstanceOf(T::class.java) + } + + override fun setUp() { + super.setUp() + objectMapper = NoOpLoginBrowser(project).objectMapper + } + + fun `test exact match, deserialization return correct BrowserMessage subtype`() { + assertDeserializedInstanceOf( + """ + { + "command": "prepareUi" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "toggleBrowser" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "selectConnection", + "connectionId": "foo" + } + """ + ).isEqualTo(BrowserMessage.SelectConnection("foo")) + + assertDeserializedInstanceOf( + """ + { + "command": "loginBuilderId" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "loginIdC", + "url": "foo", + "region": "bar", + "feature": "baz" + } + """ + ).isEqualTo( + BrowserMessage.LoginIdC( + url = "foo", + region = "bar", + feature = "baz" + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "loginIAM", + "profileName": "foo", + "accessKey": "bar", + "secretKey": "baz" + } + """ + ).isEqualTo( + BrowserMessage.LoginIAM( + profileName = "foo", + accessKey = "bar", + secretKey = "baz" + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "cancelLogin" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "signout" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "reauth" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "sendUiClickTelemetry" + } + """ + ).isEqualTo( + BrowserMessage.SendUiClickTelemetry( + signInOptionClicked = null + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "webviewTelemetry", + "event": "{ \"metricName\": \"foo\" }" + } + """.trimIndent() + ).isEqualTo( + BrowserMessage.PublishWebviewTelemetry( + event = "{ \"metricName\": \"foo\" }" + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "openUrl", + "externalLink": "foo" + } + """ + ).isEqualTo( + BrowserMessage.OpenUrl("foo") + ) + } + + fun `test unrecognizable command - deserialize should throw MismatchedInputException`() { + assertDeserializedWillThrow( + """ + { + "command": "" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "zxcasdqwe" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "foo bar baz" + } + """ + ) + } + + fun `test unknown fields - deserialize should throw MismatchedInputException`() { + assertDeserializedWillThrow( + """ + { + "command": "prepareUi", + "unknown": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "profileName": "foo", + "unknown": "bar" + } + """ + ) + } + + fun `test missing required fields - deserialize fail `() { + assertDeserializedWillThrow( + """ + { + "command": "selectConnection" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "accessKey": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIdC" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIdC", + "url": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIdC", + "region": "bar", + "feature": "baz" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "profileName": "bar" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "profileName": "bar", + "secretKey": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "accessKey": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "openUrl" + } + """ + ) + } + + fun `test Nullable fields in sendUiClickTelemetry should not throw exception`() { + assertDoesNotThrow { + objectMapper.readValue( + """ + { + "command": "sendUiClickTelemetry", + "signInOptionClicked": null + } + """ + ) + } + + assertDoesNotThrow { + objectMapper.readValue( + """ + { + "command": "sendUiClickTelemetry" + + } + """ + ) + } + } +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt new file mode 100644 index 00000000000..0649bd01280 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt @@ -0,0 +1,145 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import software.aws.toolkits.core.telemetry.MetricEvent +import software.aws.toolkits.jetbrains.core.webview.BrowserMessage +import software.aws.toolkits.jetbrains.core.webview.BrowserState +import software.aws.toolkits.jetbrains.core.webview.LoginBrowser +import software.aws.toolkits.jetbrains.services.telemetry.MockTelemetryServiceExtension + +class TestLoginBrowser(project: Project) : LoginBrowser(project) { + // test env can't initiate a real jcef and will throw error + override val jcefBrowser: JBCefBrowserBase + get() = mock() + + override fun handleBrowserMessage(message: BrowserMessage?) {} + + override fun prepareBrowser(state: BrowserState) {} + + override fun loadWebView(query: JBCefJSQuery) {} +} + +@Disabled +class LoginBrowserTest : HeavyPlatformTestCase() { + private lateinit var sut: TestLoginBrowser + private val mockTelemetryService = MockTelemetryServiceExtension() + + override fun setUp() { + super.setUp() + mockTelemetryService.beforeEach(null) + sut = TestLoginBrowser(project) + } + + override fun tearDown() { + try { + mockTelemetryService.afterEach(null) + } finally { + super.tearDown() + } + } + fun `test publish telemetry happy path`() { + val load = """ + { + "metricName": "toolkit_didLoadModule", + "module": "login", + "result": "Succeeded", + "duration": "0" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher()).enqueue(capture()) + val event = requireNotNull(firstValue.data.find { it.name == "toolkit_didLoadModule" }) + assertThat(event) + .matches { it.metadata["module"] == "login" } + .matches { it.metadata["result"] == "Succeeded" } + .matches { it.metadata["duration"] == "0.0" } + } + } + fun `test publish telemetry error path`() { + val load = """ + { + "metricName": "toolkit_didLoadModule", + "module": "login", + "result": "Failed", + "reason": "unexpected error" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher()).enqueue(capture()) + val event = requireNotNull(firstValue.data.find { it.name == "toolkit_didLoadModule" }) + assertThat(event) + .matches { it.metadata["module"] == "login" } + .matches { it.metadata["result"] == "Failed" } + .matches { it.metadata["reason"] == "unexpected error" } + } + } + fun `test missing required field will do nothing`() { + val load = """ + { + "metricName": "toolkit_didLoadModule" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + val load1 = """ + { + "metricName": "toolkit_didLoadModule", + "module": "login" + } + """.trimIndent() + val message1 = BrowserMessage.PublishWebviewTelemetry(load1) + sut.publishTelemetry(message1) + + val load2 = """ + { + "metricName": "toolkit_didLoadModule", + "result": "Failed" + } + """.trimIndent() + val message2 = BrowserMessage.PublishWebviewTelemetry(load2) + sut.publishTelemetry(message2) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher(), times(0)).enqueue(capture()) + } + } + fun `test metricName doesn't match will do nothing`() { + val load = """ + { + "metricName": "foo", + "module": "login", + "result": "Failed", + "reason": "unexpected error" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher(), times(0)).enqueue(capture()) + } + } +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt new file mode 100644 index 00000000000..35df128d564 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt @@ -0,0 +1,456 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.replaceService +import org.assertj.core.api.Assertions.assertThat +import org.mockito.Mockito.mockConstruction +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.telemetry.MetricEvent +import software.aws.toolkits.core.telemetry.TelemetryBatcher +import software.aws.toolkits.core.telemetry.TelemetryPublisher +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileSsoSessionIdentifier +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.InteractiveBearerTokenProvider +import software.aws.toolkits.jetbrains.services.telemetry.NoOpPublisher +import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.utils.isInstanceOf +import software.aws.toolkits.jetbrains.utils.isInstanceOfSatisfying +import software.aws.toolkits.jetbrains.utils.satisfiesKt + +class DefaultToolkitAuthManagerTest : HeavyPlatformTestCase() { + private class TestTelemetryService( + publisher: TelemetryPublisher = NoOpPublisher(), + batcher: TelemetryBatcher, + ) : TelemetryService(publisher, batcher) + + private lateinit var mockClientManager: MockClientManager + private lateinit var sut: DefaultToolkitAuthManager + private lateinit var connectionManager: ToolkitConnectionManager + private lateinit var batcher: TelemetryBatcher + private lateinit var telemetryService: TelemetryService + private var isTelemetryEnabledDefault: Boolean = false + + override fun setUp() { + super.setUp() + mockClientManager = service() as MockClientManager + + val ssoOidcClient = delegateMock() + @Suppress("DEPRECATION") + mockClientManager.register(SsoOidcClient::class, ssoOidcClient) + + sut = DefaultToolkitAuthManager() + ApplicationManager.getApplication().replaceService(ToolkitAuthManager::class.java, sut, testRootDisposable) + + connectionManager = DefaultToolkitConnectionManager(project) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + + batcher = mock() + telemetryService = spy(TestTelemetryService(batcher = batcher)) + connectionManager = DefaultToolkitConnectionManager(project) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + isTelemetryEnabledDefault = AwsSettings.getInstance().isTelemetryEnabled + } + + override fun tearDown() { + try { + telemetryService.dispose() + AwsSettings.getInstance().isTelemetryEnabled = isTelemetryEnabledDefault + } finally { + super.tearDown() + } + } + + fun `test creates ManagedBearerSsoConnection from ManagedSsoProfile`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + val connection = sut.createConnection(profile) + + assertThat(connection).isInstanceOf() + connection as ManagedBearerSsoConnection + assertThat(connection.sessionName).isEqualTo("") + assertThat(connection.region).isEqualTo(profile.ssoRegion) + assertThat(connection.startUrl).isEqualTo(profile.startUrl) + assertThat(connection.scopes).isEqualTo(profile.scopes) + } + + fun `test creates ManagedBearerSsoConnection from serialized ManagedSsoProfile`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + sut.createConnection(profile) + + assertThat(sut.state?.ssoProfiles).satisfiesKt { profiles -> + assertThat(profiles).isNotNull() + assertThat(profiles).singleElement().isEqualTo(profile) + } + } + + fun `test serializes ManagedSsoProfile from ManagedBearerSsoConnection`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + + sut.loadState( + ToolkitAuthManagerState( + ssoProfiles = listOf(profile) + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("") + assertThat(connection.region).isEqualTo(profile.ssoRegion) + assertThat(connection.startUrl).isEqualTo(profile.startUrl) + assertThat(connection.scopes).isEqualTo(profile.scopes) + } + } + } + + fun `test loadState dedupes profiles`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + + sut.loadState( + ToolkitAuthManagerState( + ssoProfiles = listOf( + profile, + profile, + profile + ) + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("") + assertThat(connection.region).isEqualTo(profile.ssoRegion) + assertThat(connection.startUrl).isEqualTo(profile.startUrl) + assertThat(connection.scopes).isEqualTo(profile.scopes) + } + } + } + + fun `test updates connection list from connection bus`() { + assertThat(sut.listConnections()).isEmpty() + + val scopes = listOf("scope1", "scope2") + val publisher = ApplicationManager.getApplication().messageBus.syncPublisher(CredentialManager.CREDENTIALS_CHANGED) + + publisher.ssoSessionAdded( + ProfileSsoSessionIdentifier( + "add", + "startUrl", + "us-east-1", + scopes.toSet() + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("add") + assertThat(connection.region).isEqualTo("us-east-1") + assertThat(connection.startUrl).isEqualTo("startUrl") + assertThat(connection.scopes).isEqualTo(scopes) + } + } + + publisher.ssoSessionModified( + ProfileSsoSessionIdentifier( + "add", + "startUrl2", + "us-east-1", + scopes.toSet() + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("add") + assertThat(connection.region).isEqualTo("us-east-1") + assertThat(connection.startUrl).isEqualTo("startUrl2") + assertThat(connection.scopes).isEqualTo(scopes) + } + } + + publisher.ssoSessionRemoved( + ProfileSsoSessionIdentifier( + "add", + "startUrl2", + "us-east-1", + scopes.toSet() + ) + ) + + assertThat(sut.listConnections()).isEmpty() + } + + fun `test loginSso with an working existing connection`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.AUTHORIZED) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("scopes") + ) + ) + + loginSso(project, "foo", "us-east-1", listOf("scopes")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider).state() + verifyNoMoreInteractions(tokenProvider) + } + } + + fun `test loginSso with an existing connection but expired and refresh token is valid, should refreshToken`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.id).thenReturn("id") + whenever(context.state()).thenReturn(BearerTokenAuthState.NEEDS_REFRESH) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("scopes") + ) + ) + connectionManager.switchConnection(existingConnection) + + loginSso(project, "foo", "us-east-1", listOf("scopes")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider).resolveToken() + assertThat(connectionManager.activeConnection()).isEqualTo(existingConnection) + } + } + + fun `test loginSso with an existing connection that token is invalid and there's no refresh token, should re-authenticate`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.NOT_AUTHENTICATED) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("scopes") + ) + ) + connectionManager.switchConnection(existingConnection) + + loginSso(project, "foo", "us-east-1", listOf("scopes")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider, timeout(5000)).reauthenticate() + assertThat(connectionManager.activeConnection()).isEqualTo(existingConnection) + } + } + + fun `test loginSso reuses connection if requested scopes are subset of existing`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.AUTHORIZED) + }.use { + val connectionManager = spy(connectionManager) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("existing1", "existing2", "existing3") + ) + ) + + connectionManager.switchConnection(existingConnection) + + loginSso(project, "foo", "us-east-1", listOf("existing1")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider).state() + verifyNoMoreInteractions(tokenProvider) + assertThat(connectionManager.activeConnection()).isEqualTo(existingConnection) + verify(connectionManager, atLeastOnce()).switchConnection(existingConnection) + } + } + + fun `test loginSso forces reauth if requested scopes are not complete subset`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.AUTHORIZED) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("existing1", "existing2", "existing3") + ) + ) + + val newScopes = listOf("existing1", "new1") + loginSso(project, "foo", "us-east-1", newScopes) + + assertThat(connectionManager.activeConnection() as AwsBearerTokenConnection).satisfiesKt { connection -> + assertThat(connection.scopes.toSet()).isEqualTo(setOf("existing1", "existing2", "existing3", "new1")) + } + assertThat(sut.listConnections()).singleElement().isInstanceOfSatisfying { connection -> + assertThat(connection).usingRecursiveComparison().isNotEqualTo(existingConnection) + assertThat(connection.scopes.toSet()).isEqualTo(setOf("existing1", "existing2", "existing3", "new1")) + } + } + } + + fun `test loginSso with a new connection`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + doNothing().whenever(context).reauthenticate() + whenever(context.state()).thenReturn(BearerTokenAuthState.NOT_AUTHENTICATED) + }.use { + val connectionManager = spy(connectionManager) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + // before + assertThat(sut.listConnections()).hasSize(0) + + loginSso(project, "foo", "us-east-1", listOf("scope1", "scope2")) + + // after + assertThat(sut.listConnections()).hasSize(1) + verify(connectionManager, timeout(5000)).switchConnection(any()) + + val expectedConnection = LegacyManagedBearerSsoConnection( + "foo", + "us-east-1", + listOf("scope1", "scope2") + ) + + sut.listConnections()[0].let { conn -> + assertThat(conn.getConnectionSettings()) + .usingRecursiveComparison() + .isEqualTo(expectedConnection.getConnectionSettings()) + assertThat(conn.id).isEqualTo(expectedConnection.id) + assertThat(conn.label).isEqualTo(expectedConnection.label) + } + } + } + + fun `test logoutFromConnection should invalidate the token provider and the connection and invoke callback`() { + val profile = ManagedSsoProfile("us-east-1", "startUrl000", listOf("scopes")) + val connection = sut.createConnection(profile) as ManagedBearerSsoConnection + connectionManager.switchConnection(connection) + + var providerInvalidatedMessageReceived = 0 + var connectionSwitchedMessageReceived = 0 + var callbackInvoked = 0 + ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun invalidate(providerId: String) { + if (providerId == "sso;us-east-1;startUrl000") { + providerInvalidatedMessageReceived += 1 + } + } + } + ) + ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + connectionSwitchedMessageReceived += 1 + } + } + ) + + logoutFromSsoConnection(project, connection) { callbackInvoked += 1 } + assertThat(providerInvalidatedMessageReceived).isEqualTo(1) + assertThat(connectionSwitchedMessageReceived).isEqualTo(1) + assertThat(callbackInvoked).isEqualTo(1) + } + + fun `test loginSso telemetry contains default source ID`() { + AwsSettings.getInstance().isTelemetryEnabled = true + loginSso( + project = project, + startUrl = "foo", + region = "us-east-1", + requestedScopes = listOf("scopes") + ) + val metricCaptor = argumentCaptor() + assertThat(metricCaptor.allValues).allSatisfy { event -> + assertThat(event.data.all { it.metadata["credentialSourceId"] == "awsId" }).isTrue() + } + } + + fun `test loginSso telemetry contains no source by default`() { + AwsSettings.getInstance().isTelemetryEnabled = true + loginSso( + project = project, + startUrl = "foo", + region = "us-east-1", + requestedScopes = listOf("scopes") + ) + val metricCaptor = argumentCaptor() + assertThat(metricCaptor.allValues).allSatisfy { event -> + assertThat(event.data.all { it.metadata["source"] == null }).isTrue() + } + } + + fun `test loginSso telemetry contains provided source`() { + AwsSettings.getInstance().isTelemetryEnabled = true + loginSso( + project = project, + startUrl = "foo", + region = "us-east-1", + requestedScopes = listOf("scopes"), + metadata = ConnectionMetadata("fooSource") + ) + val metricCaptor = argumentCaptor() + assertThat(metricCaptor.allValues).allSatisfy { event -> + assertThat(event.data.all { it.metadata["source"] == "fooSourceId" }).isTrue() + } + } + + fun `test serializing LegacyManagedBearerSsoConnection does not include connectionSettings`() { + val profile = ManagedSsoProfile("us-east-1", "startUrl000", listOf("scopes")) + val connection = sut.createConnection(profile) as LegacyManagedBearerSsoConnection + + assertThat(jacksonObjectMapper().writeValueAsString(connection)).doesNotContain("connectionSettings") + } + + fun `test serializing ProfileSsoManagedBearerSsoConnection does not include connectionSettings`() { + val profile = UserConfigSsoSessionProfile("sessionName", "us-east-1", "startUrl000", listOf("scopes")) + val connection = sut.createConnection(profile) as ProfileSsoManagedBearerSsoConnection + + assertThat(jacksonObjectMapper().writeValueAsString(connection)).doesNotContain("connectionSettings") + } + +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt new file mode 100644 index 00000000000..e244456d14f --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt @@ -0,0 +1,102 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.openapi.components.service +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.sso.model.RoleInfo +import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.credentials.ConfigFilesFacade +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import software.aws.toolkits.resources.AwsCoreBundle + +class IdcRolePopupTest : HeavyPlatformTestCase() { + private lateinit var mockClientManager: MockClientManager + + + override fun setUp() { + super.setUp() + mockClientManager = service() as MockClientManager + + @Suppress("DEPRECATION") + mockClientManager.register(SsoClient::class, delegateMock()) + } + + fun `test validate role selected`() { + val state = IdcRolePopupState() + + runInEdtAndWait { + val validation = IdcRolePopup(project, aString(), aString(), mockk(), state, mockk()).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).singleElement().satisfiesKt { + assertThat(it.okEnabled).isFalse() + assertThat(it.message).contains(AwsCoreBundle.message("gettingstarted.setup.error.not_selected")) + } + } + } + + fun `test success writes profile to config`() { + val sessionName = aString() + val roleInfo = RoleInfo.builder() + .roleName(aString()) + .accountId(aString()) + .build() + val state = IdcRolePopupState().apply { + this.roleInfo = roleInfo + } + val configFilesFacade = mockk { + every { readAllProfiles() } returns emptyMap() + justRun { appendProfileToConfig(any()) } + } + + + runInEdtAndWait { + val sut = IdcRolePopup( + project, + region = aString(), + sessionName = sessionName, + tokenProvider = mockk(), + state = state, + configFilesFacade = configFilesFacade + ) + try { + sut.doOkActionWithRoleInfo(roleInfo) + } finally { + sut.close(0) + } + + verify { + configFilesFacade.appendProfileToConfig( + Profile.builder() + .name("$sessionName-${roleInfo.accountId()}-${roleInfo.roleName()}") + .properties( + mapOf( + "sso_session" to sessionName, + "sso_account_id" to roleInfo.accountId(), + "sso_role_name" to roleInfo.roleName() + ) + ) + .build() + ) + } + } + } +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt new file mode 100644 index 00000000000..6639e5e3415 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt @@ -0,0 +1,340 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.TestDialog +import com.intellij.openapi.ui.TestDialogManager +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.stub +import org.mockito.kotlin.whenever +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.services.sts.StsClient +import software.amazon.awssdk.services.sts.model.GetCallerIdentityRequest +import software.amazon.awssdk.services.sts.model.GetCallerIdentityResponse +import software.amazon.awssdk.services.sts.model.StsException +import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.region.Endpoint +import software.aws.toolkits.core.region.Service +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.credentials.ConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.UserConfigSsoSessionProfile +import software.aws.toolkits.jetbrains.core.credentials.authAndUpdateConfig +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.SourceOfEntry +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderExtension +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import software.aws.toolkits.resources.AwsCoreBundle +import software.aws.toolkits.telemetry.FeatureId + +class SetupAuthenticationDialogTest : HeavyPlatformTestCase() { + private lateinit var mockClientManager: MockClientManager + private val mockRegionProvider = MockRegionProviderExtension() + + override fun setUp() { + super.setUp() + mockClientManager = service() as MockClientManager + } + + fun `test login to IdC tab`() { + mockkStatic(::authAndUpdateConfig) + + val startUrl = aString() + val region = mockRegionProvider.createAwsRegion() + val scopes = listOf(aString(), aString(), aString()) + mockRegionProvider.addService( + "sso", + Service( + endpoints = mapOf(region.id to Endpoint()), + isRegionalized = true, + partitionEndpoint = region.partitionId + ) + ) + + val configFacade = mockk(relaxed = true) + TestDialogManager.setTestDialog(TestDialog.OK) + val state = SetupAuthenticationDialogState().apply { + idcTabState.apply { + this.startUrl = startUrl + this.region = region + } + } + + runInEdtAndWait { + SetupAuthenticationDialog( + project, + scopes = scopes, + state = state, + configFilesFacade = configFacade, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).apply { + try { + doOKAction() + } finally { + close(0) + } + } + } + + verify { + authAndUpdateConfig( + project, + UserConfigSsoSessionProfile("", region.id, startUrl, scopes), + configFacade, + any(), + any(), + any() + ) + } + } + + fun `test login to IdC tab and request role`() { + mockkStatic(::authAndUpdateConfig) + + val startUrl = aString() + val region = mockRegionProvider.createAwsRegion() + val scopes = listOf(aString(), aString(), aString()) + mockRegionProvider.addService( + "sso", + Service( + endpoints = mapOf(region.id to Endpoint()), + isRegionalized = true, + partitionEndpoint = region.partitionId + ) + ) + + val configFacade = mockk(relaxed = true) + TestDialogManager.setTestDialog(TestDialog.OK) + val state = SetupAuthenticationDialogState().apply { + idcTabState.apply { + this.startUrl = startUrl + this.region = region + } + } + + runInEdtAndWait { + SetupAuthenticationDialog( + project, + scopes = scopes, + state = state, + promptForIdcPermissionSet = true, + configFilesFacade = configFacade, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).apply { + try { + doOKAction() + } finally { + close(0) + } + } + } + + verify { + authAndUpdateConfig( + project, + UserConfigSsoSessionProfile("", region.id, startUrl, scopes + "sso:account:access"), + configFacade, + any(), + any(), + any() + ) + } + } + + fun `test login to Builder ID tab`() { + mockkStatic(::loginSso) + every { loginSso(any(), any(), any(), any()) } answers { mockk() } + + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.BUILDER_ID) + } + + runInEdtAndWait { + SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).apply { + try { + doOKAction() + } finally { + close(0) + } + } + } + + verify { + loginSso(project, SONO_URL, SONO_REGION, emptyList()) + } + } + + fun `test validate IdC tab`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IDENTITY_CENTER) + } + + runInEdtAndWait { + val validation = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).satisfiesKt { + assertThat(it).hasSize(2) + assertThat(it).allSatisfy { error -> + assertThat(error.message).contains("Must not be empty") + } + } + } + } + + fun `test validate Builder ID tab`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.BUILDER_ID) + } + + runInEdtAndWait { + val validation = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).isEmpty() + } + } + + fun `test validate IAM tab`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IAM_LONG_LIVED) + iamTabState.profileName = "" + } + + runInEdtAndWait { + val validation = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).satisfiesKt { + assertThat(it).hasSize(3) + assertThat(it).allSatisfy { error -> + assertThat(error.message).contains("Must not be empty") + } + } + } + } + + // TODO: Fix StsClient mock exception throwing in 2025.3 migration - this test expects an exception but mock doesn't throw + fun `test validate IAM tab fails if credentials are invalid`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IAM_LONG_LIVED) + iamTabState.apply { + profileName = "test" + accessKey = "invalid" + secretKey = "invalid" + } + } + + val stsClient = delegateMock() + @Suppress("DEPRECATION") + mockClientManager.register(StsClient::class, stsClient) + stsClient.stub { + whenever(it.getCallerIdentity(any())).thenThrow(StsException.builder().message("Some service exception message").build()) + } + + runInEdtAndWait { + val sut = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ) + val exception = assertThrows { sut.doOKAction() } + assertThat(exception.message).isEqualTo(AwsCoreBundle.message("gettingstarted.setup.iam.profile.invalid_credentials")) + } + } + + fun `test validate IAM tab succeeds if credentials are invalid`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IAM_LONG_LIVED) + iamTabState.apply { + profileName = "test" + accessKey = "validAccess" + secretKey = "validSecret" + } + } + + val stsClient = delegateMock() + @Suppress("DEPRECATION") + mockClientManager.register(StsClient::class, stsClient) + stsClient.stub { + whenever(it.getCallerIdentity(any())).thenReturn(GetCallerIdentityResponse.builder().build()) + } + + val configFacade = mockk(relaxed = true) + runInEdtAndWait { + SetupAuthenticationDialog( + project, + state = state, + configFilesFacade = configFacade, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ) + .doOKAction() + } + + verify { + configFacade.appendProfileToCredentials( + Profile.builder() + .name("test") + .properties( + mapOf( + "aws_access_key_id" to "validAccess", + "aws_secret_access_key" to "validSecret" + ) + ) + .build() + ) + } + } +} From 2b97dc299896f77dd784c3830544276e88ef50e7 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Wed, 12 Nov 2025 21:45:23 -0800 Subject: [PATCH 14/35] Fix detekt issues in tst-253+ test files Auto-corrected formatting and style issues in migrated test files. --- .../software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt | 2 -- .../jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt | 1 - .../toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt | 2 -- .../jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt | 1 - .../core/gettingstarted/GettingStartedOnStartupTest.kt | 1 - 5 files changed, 7 deletions(-) diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt index 7148c3c70f2..9b04da18ccd 100644 --- a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt @@ -32,8 +32,6 @@ class NoOpLoginBrowser(project: Project) : LoginBrowser(project) { class BrowserMessageTest : HeavyPlatformTestCase() { private lateinit var objectMapper: ObjectMapper - - private inline fun assertDeserializedInstanceOf(jsonStr: String): ObjectAssert { val actual = objectMapper.readValue(jsonStr) return assertThat(actual).isInstanceOf(T::class.java) diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt index 35df128d564..1d43e5b8dc3 100644 --- a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt @@ -452,5 +452,4 @@ class DefaultToolkitAuthManagerTest : HeavyPlatformTestCase() { assertThat(jacksonObjectMapper().writeValueAsString(connection)).doesNotContain("connectionSettings") } - } diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt index e244456d14f..4d794ec5692 100644 --- a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt @@ -25,7 +25,6 @@ import software.aws.toolkits.resources.AwsCoreBundle class IdcRolePopupTest : HeavyPlatformTestCase() { private lateinit var mockClientManager: MockClientManager - override fun setUp() { super.setUp() mockClientManager = service() as MockClientManager @@ -67,7 +66,6 @@ class IdcRolePopupTest : HeavyPlatformTestCase() { justRun { appendProfileToConfig(any()) } } - runInEdtAndWait { val sut = IdcRolePopup( project, diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt index dd091f68bc9..97622dd79ed 100644 --- a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt @@ -13,7 +13,6 @@ import software.aws.toolkits.jetbrains.core.ToolWindowHeadlessManagerImpl class AwsToolkitExplorerToolWindowTest : HeavyPlatformTestCase() { - fun `test save current tab state`() { (ToolWindowManager.getInstance(project) as ToolWindowHeadlessManagerImpl) .doRegisterToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt index 10331c256c1..a22f9339eda 100644 --- a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt @@ -6,7 +6,6 @@ package software.aws.toolkits.jetbrains.core.gettingstarted import com.intellij.configurationStore.getPersistentStateComponentStorageLocation import com.intellij.testFramework.HeavyPlatformTestCase import io.mockk.every -import io.mockk.junit5.MockKExtension import io.mockk.mockkObject import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi From 02e01ef075040321bf8db8bb6c03bc0e468bbc8a Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Wed, 12 Nov 2025 22:45:28 -0800 Subject: [PATCH 15/35] Add LSP4J dependency to AmazonQ modules for 2025.3 - Add api(libs.lsp4j) to amazonq/shared module - Add implementation(libs.lsp4j) to amazonq/codewhisperer module - Resolves unresolved reference errors for LSP types --- .../jetbrains-community/build.gradle.kts | 1 + .../jetbrains-community/build.gradle.kts | 1 + .../jetbrains/CwmProblemsViewMutator.kt | 0 .../amazonq/lsp/AmazonQLanguageClientImpl.kt | 0 .../TextDocumentServiceHandler.kt | 0 .../jetbrains/CwmProblemsViewMutator.kt | 14 + .../amazonq/lsp/AmazonQLanguageClientImpl.kt | 607 ++++++++++++++++++ .../TextDocumentServiceHandler.kt | 260 ++++++++ 8 files changed, 883 insertions(+) rename plugins/amazonq/shared/jetbrains-community/{src => src-242-252}/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt (100%) rename plugins/amazonq/shared/jetbrains-community/{src => src-242-252}/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt (100%) rename plugins/amazonq/shared/jetbrains-community/{src => src-242-252}/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt (100%) create mode 100644 plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt create mode 100644 plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt create mode 100644 plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts index 015c6746975..824d0f016ff 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { compileOnly(project(":plugin-core:jetbrains-community")) implementation(project(":plugin-amazonq:shared:jetbrains-community")) + implementation(libs.lsp4j) // CodeWhispererTelemetryService uses a CircularFifoQueue, previously transitive from zjsonpatch implementation(libs.commons.collections) diff --git a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts index 205c6806b86..36313073d9b 100644 --- a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // CodeWhispererTelemetryService uses a CircularFifoQueue implementation(libs.commons.collections) implementation(libs.nimbus.jose.jwt) + api(libs.lsp4j) testFixturesApi(testFixtures(project(":plugin-core:jetbrains-community"))) } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt rename to plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt rename to plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt 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-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt rename to plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt diff --git a/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt new file mode 100644 index 00000000000..3c0026cbab8 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt @@ -0,0 +1,14 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains + +import com.intellij.analysis.problemsView.toolWindow.ProblemsView +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow + +class CwmProblemsViewMutator : ProblemsViewMutator { + override fun mutateProblemsView(project: Project, runnable: (ToolWindow) -> Unit) { + ProblemsView.getToolWindow(project)?.let { runnable(it) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt new file mode 100644 index 00000000000..981b4152865 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt @@ -0,0 +1,607 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +@file:Suppress("BannedImports") + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.intellij.diff.DiffContentFactory +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.ide.BrowserUtil +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.fileChooser.FileChooserFactory +import com.intellij.openapi.fileChooser.FileSaverDescriptor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFileManager +import migration.software.aws.toolkits.jetbrains.settings.AwsSettings +import org.eclipse.lsp4j.ApplyWorkspaceEditParams +import org.eclipse.lsp4j.ApplyWorkspaceEditResponse +import org.eclipse.lsp4j.ConfigurationParams +import org.eclipse.lsp4j.MessageActionItem +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.MessageType +import org.eclipse.lsp4j.ProgressParams +import org.eclipse.lsp4j.PublishDiagnosticsParams +import org.eclipse.lsp4j.ShowDocumentParams +import org.eclipse.lsp4j.ShowDocumentResult +import org.eclipse.lsp4j.ShowMessageRequestParams +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException +import org.eclipse.lsp4j.jsonrpc.messages.ResponseError +import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode +import org.slf4j.event.Level +import software.amazon.awssdk.utils.UserHomeDirectoryUtils +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.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.ChatCommunicationManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.FlareUiMessage +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LSPAny +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_OPEN_TAB +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_OPTIONS_UPDATE_NOTIFICATION +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_PINNED_CONTEXT_ADD +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_PINNED_CONTEXT_REMOVE +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_SEND_CONTEXT_COMMANDS +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_SEND_PINNED_CONTEXT +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_SEND_UPDATE +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CopyFileParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.FileParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GET_SERIALIZED_CHAT_REQUEST_METHOD +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GetSerializedChatResult +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OpenFileDiffParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowOpenFileDialogParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogResult +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.TelemetryParsingUtil +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.applyExtensionFilter +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.utils.getCleanedContent +import software.aws.toolkits.jetbrains.utils.notify +import software.aws.toolkits.resources.message +import java.io.File +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * Concrete implementation of [AmazonQLanguageClient] to handle messages sent from server + */ +class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageClient { + private val chatManager + get() = ChatCommunicationManager.getInstance(project) + private fun handleTelemetryMap(telemetryMap: Map<*, *>) { + try { + val name = telemetryMap["name"] as? String ?: return + + @Suppress("UNCHECKED_CAST") + val data = telemetryMap["data"] as? Map ?: return + + TelemetryService.getInstance().record(project) { + datum(name) { + unit(TelemetryParsingUtil.parseMetricUnit(telemetryMap["unit"])) + value(telemetryMap["value"] as? Double ?: 1.0) + passive(telemetryMap["passive"] as? Boolean ?: false) + + telemetryMap["result"]?.let { result -> + metadata("result", result.toString()) + } + + data.forEach { (key, value) -> + metadata(key, value?.toString() ?: "null") + } + } + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to process telemetry event: $telemetryMap" } + } + } + + override fun telemetryEvent(`object`: Any) { + when (`object`) { + is Map<*, *> -> handleTelemetryMap(`object`) + else -> LOG.warn { "Unexpected telemetry event: $`object`" } + } + } + + override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) { + println(diagnostics) + } + + override fun showMessage(messageParams: MessageParams) { + notify( + messageParams.type.toNotificationType(), + message("toolwindow.stripe.amazon.q.window"), + getCleanedContent(messageParams.message, true), + project, + emptyList() + ) + } + + override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture { + val future = CompletableFuture() + if (requestParams.actions.isNullOrEmpty()) { + future.complete(null) + } + + notify( + requestParams.type.toNotificationType(), + message("toolwindow.stripe.amazon.q.window"), + getCleanedContent(requestParams.message, true), + project, + requestParams.actions.map { item -> + NotificationAction.createSimple(item.title) { + future.complete(item) + } + } + ) + + return future + } + + override fun logMessage(message: MessageParams) { + val type = when (message.type) { + MessageType.Error -> Level.ERROR + MessageType.Warning -> Level.WARN + MessageType.Info, MessageType.Log -> Level.INFO + else -> Level.WARN + } + + if (type == Level.ERROR && + message.message.lineSequence().firstOrNull()?.contains("NOTE: The AWS SDK for JavaScript (v2) is in maintenance mode.") == true + ) { + LOG.info { "Suppressed Flare AWS JS SDK v2 EoL error message" } + return + } + + LOG.atLevel(type).log(message.message) + } + + override fun showDocument(params: ShowDocumentParams): CompletableFuture { + try { + if (params.uri.isNullOrEmpty()) { + return CompletableFuture.completedFuture(ShowDocumentResult(false)) + } + + if (params.external == true) { + BrowserUtil.open(params.uri) + return CompletableFuture.completedFuture(ShowDocumentResult(true)) + } + + // The filepath sent by the server contains unicode characters which need to be + // decoded for JB file handling APIs to be handle to handle file operations + val fileToOpen = URLDecoder.decode(params.uri, StandardCharsets.UTF_8.name()) + return CompletableFuture.supplyAsync( + { + try { + val virtualFile = VirtualFileManager.getInstance().refreshAndFindFileByUrl(fileToOpen) + ?: throw IllegalArgumentException("Cannot find file: $fileToOpen") + + FileEditorManager.getInstance(project).openFile(virtualFile, true) + ShowDocumentResult(true) + } catch (e: Exception) { + LOG.warn { "Failed to show document: $fileToOpen" } + ShowDocumentResult(false) + } + }, + ApplicationManager.getApplication()::invokeLater + ) + } catch (e: Exception) { + LOG.warn { "Error showing document" } + return CompletableFuture.completedFuture(ShowDocumentResult(false)) + } + } + + override fun getConnectionMetadata(): CompletableFuture = + CompletableFuture.supplyAsync { + val connection = ToolkitConnectionManager.getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) + + connection?.let { ConnectionMetadata.fromConnection(it) } + } + + override fun openTab(params: LSPAny): CompletableFuture { + val requestId = UUID.randomUUID().toString() + val result = CompletableFuture() + chatManager.addTabOpenRequest(requestId, result) + + chatManager.notifyUi( + FlareUiMessage( + command = CHAT_OPEN_TAB, + params = params, + requestId = requestId, + ) + ) + + result.orTimeout(30000, TimeUnit.MILLISECONDS) + .whenComplete { _, error -> + chatManager.removeTabOpenRequest(requestId) + } + + return result + } + + override fun showSaveFileDialog(params: ShowSaveFileDialogParams): CompletableFuture { + val filters = mutableListOf() + val formatMappings = mapOf("markdown" to "md", "html" to "html") + + params.supportedFormats.forEach { format -> + formatMappings[format]?.let { filters.add(it) } + } + val defaultUri = params.defaultUri ?: "export-chat.md" + val saveAtUri = defaultUri.substring(defaultUri.lastIndexOf("/") + 1) + return CompletableFuture.supplyAsync( + { + val descriptor = FileSaverDescriptor("Export", "Choose a location to export").apply { + withFileFilter { file -> + filters.any { ext -> + file.name.endsWith(".$ext") + } + } + } + + val chosenFile = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project).save(saveAtUri) + + chosenFile?.let { + ShowSaveFileDialogResult(chosenFile.file.path) + } ?: throw ResponseErrorException(ResponseError(ResponseErrorCode.RequestCancelled, "Export cancelled by user", null)) + }, + ApplicationManager.getApplication()::invokeLater + ) + } + + override fun showOpenFileDialog(params: ShowOpenFileDialogParams): CompletableFuture = + CompletableFuture.supplyAsync( + { + // Handle the case where both canSelectFiles and canSelectFolders are false (should never be sent from flare) + if (!params.canSelectFiles && !params.canSelectFolders) { + return@supplyAsync mapOf("uris" to emptyList()) as LSPAny + } + + val descriptor = when { + params.canSelectFolders && params.canSelectFiles -> { + if (params.canSelectMany) { + FileChooserDescriptorFactory.createAllButJarContentsDescriptor() + } else { + FileChooserDescriptorFactory.createSingleFileOrFolderDescriptor() + } + } + params.canSelectFolders -> { + if (params.canSelectMany) { + FileChooserDescriptorFactory.createMultipleFoldersDescriptor() + } else { + FileChooserDescriptorFactory.createSingleFolderDescriptor() + } + } + else -> { + if (params.canSelectMany) { + FileChooserDescriptorFactory.createMultipleFilesNoJarsDescriptor() + } else { + FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() + } + } + }.apply { + withTitle( + params.title ?: when { + params.canSelectFolders && params.canSelectFiles -> "Select Files or Folders" + params.canSelectFolders -> "Select Folders" + else -> "Select Files" + } + ) + withDescription( + when { + params.canSelectFolders && params.canSelectFiles -> "Choose files or folders to open" + params.canSelectFolders -> "Choose folders to open" + else -> "Choose files to open" + } + ) + + // Apply file filters if provided + if (params.filters.isNotEmpty() && !params.canSelectFolders) { + // Create a combined list of all allowed extensions + val allowedExtensions = params.filters.values.flatten().toSet() + applyExtensionFilter(this, "Images", allowedExtensions) + } + } + + val chosenFiles = FileChooser.chooseFiles(descriptor, project, null) + val uris = chosenFiles.map { it.path } + + mapOf("uris" to uris) as LSPAny + }, + ApplicationManager.getApplication()::invokeLater + ) + + override fun getSerializedChat(params: LSPAny): CompletableFuture { + val requestId = UUID.randomUUID().toString() + val result = CompletableFuture() + chatManager.addSerializedChatRequest(requestId, result) + + chatManager.notifyUi( + FlareUiMessage( + command = GET_SERIALIZED_CHAT_REQUEST_METHOD, + params = params, + requestId = requestId, + ) + ) + + result.orTimeout(30000, TimeUnit.MILLISECONDS) + .whenComplete { _, error -> + chatManager.removeSerializedChatRequest(requestId) + } + + return result + } + + override fun configuration(params: ConfigurationParams): CompletableFuture> { + if (params.items.isEmpty()) { + return CompletableFuture.completedFuture(null) + } + + return CompletableFuture.completedFuture( + buildList { + val qSettings = CodeWhispererSettings.getInstance() + params.items.forEach { + when (it.section) { + AmazonQLspConstants.LSP_CW_CONFIGURATION_KEY -> { + add( + CodeWhispererLspConfiguration( + shouldShareData = qSettings.isMetricOptIn(), + shouldShareCodeReferences = qSettings.isIncludeCodeWithReference(), + // server context + shouldEnableWorkspaceContext = qSettings.isWorkspaceContextEnabled() + ) + ) + } + + AmazonQLspConstants.LSP_Q_CONFIGURATION_KEY -> { + add( + AmazonQLspConfiguration( + optOutTelemetry = !AwsSettings.getInstance().isTelemetryEnabled, + customization = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn, + // local context + projectContext = ProjectContextConfiguration( + enableLocalIndexing = qSettings.isProjectContextEnabled(), + indexWorkerThreads = qSettings.getProjectContextIndexThreadCount(), + enableGpuAcceleration = qSettings.isProjectContextGpu(), + localIndexing = LocalIndexingConfiguration( + maxIndexSizeMB = qSettings.getProjectContextIndexMaxSize() + ) + ) + ) + ) + } + } + } + } + ) + } + + override fun notifyProgress(params: ProgressParams?) { + if (params == null) return + try { + chatManager.handlePartialResultProgressNotification(project, params) + } catch (e: Exception) { + LOG.error(e) { "Cannot handle partial chat" } + } + } + + override fun sendChatUpdate(params: LSPAny) { + chatManager.notifyUi( + FlareUiMessage( + command = CHAT_SEND_UPDATE, + params = params, + ) + ) + } + + private fun File.toVirtualFile() = LocalFileSystem.getInstance().findFileByIoFile(this) + + private fun MessageType.toNotificationType() = when (this) { + MessageType.Error -> NotificationType.ERROR + MessageType.Warning -> NotificationType.WARNING + MessageType.Info, MessageType.Log -> NotificationType.INFORMATION + } + + override fun openFileDiff(params: OpenFileDiffParams) { + ApplicationManager.getApplication().invokeLater { + var tempPath: java.nio.file.Path? = null + try { + val fileName = Paths.get(params.originalFileUri).fileName.toString() + // Create a temporary virtual file for syntax highlighting + val fileExtension = fileName.substringAfterLast('.', "") + tempPath = Files.createTempFile(null, ".$fileExtension") + val virtualFile = tempPath.toFile() + .also { it.setReadOnly() } + .toVirtualFile() + + val originalContent = params.originalFileContent ?: run { + val sourceFile = File(params.originalFileUri) + if (sourceFile.exists()) sourceFile.readText() else "" + } + + val contentFactory = DiffContentFactory.getInstance() + var isNewFile = false + val (leftContent, rightContent) = when { + params.isDeleted -> { + contentFactory.create(project, originalContent, virtualFile) to + contentFactory.createEmpty() + } + + else -> { + val newContent = params.fileContent.orEmpty() + isNewFile = newContent == originalContent + when { + isNewFile -> { + contentFactory.createEmpty() to + contentFactory.create(project, newContent, virtualFile) + } + + else -> { + contentFactory.create(project, originalContent, virtualFile) to + contentFactory.create(project, newContent, virtualFile) + } + } + } + } + val diffRequest = SimpleDiffRequest( + "$fileName ${message("aws.q.lsp.client.diff_message")}", + leftContent, + rightContent, + "Original", + when { + params.isDeleted -> "Deleted" + isNewFile -> "Created" + else -> "Modified" + } + ) + + AmazonQDiffVirtualFile.openDiff(project, diffRequest) + } catch (e: Exception) { + LOG.warn { "Failed to open file diff: ${e.message}" } + } finally { + // Clean up the temporary file used for syntax highlight + try { + tempPath?.let { Files.deleteIfExists(it) } + } catch (e: Exception) { + LOG.warn { "Failed to delete temporary file: ${e.message}" } + } + } + } + } + + override fun sendContextCommands(params: LSPAny) { + chatManager.notifyUi( + FlareUiMessage( + command = CHAT_SEND_CONTEXT_COMMANDS, + params = params, + ) + ) + } + + override fun sendPinnedContext(params: LSPAny) { + // Send the active text file path with pinned context + val editor = FileEditorManager.getInstance(project).selectedTextEditor + val textDocument = editor?.let { + it.virtualFile?.let { virtualFile -> + val relativePath = VfsUtilCore.getRelativePath(virtualFile, project.baseDir) + ?: virtualFile.path // Use absolute path if not in project + TextDocumentIdentifier(relativePath) + } + } + + // Create updated params with text document information + // Since params is LSPAny, we need to handle it as a generic object + val updatedParams = when (params) { + is Map<*, *> -> { + val mutableParams = params.toMutableMap() + mutableParams["textDocument"] = textDocument + mutableParams + } + else -> mapOf( + "params" to params, + "textDocument" to textDocument + ) + } + + chatManager.notifyUi( + FlareUiMessage( + command = CHAT_SEND_PINNED_CONTEXT, + params = updatedParams, + ) + ) + } + + override fun pinnedContextAdd(params: LSPAny) { + chatManager.notifyUi( + FlareUiMessage( + command = CHAT_PINNED_CONTEXT_ADD, + params = params, + ) + ) + } + + override fun pinnedContextRemove(params: LSPAny) { + chatManager.notifyUi( + FlareUiMessage( + command = CHAT_PINNED_CONTEXT_REMOVE, + params = params, + ) + ) + } + + override fun appendFile(params: FileParams) = refreshVfs(params.path) + + override fun createDirectory(params: FileParams) = refreshVfs(params.path) + + override fun removeFile(params: FileParams) = refreshVfs(params.path) + + override fun writeFile(params: FileParams) = refreshVfs(params.path) + + override fun copyFile(params: CopyFileParams) { + refreshVfs(params.oldPath) + return refreshVfs(params.newPath) + } + + override fun sendChatOptionsUpdate(params: LSPAny) { + chatManager.notifyUi( + FlareUiMessage( + command = CHAT_OPTIONS_UPDATE_NOTIFICATION, + params = params, + ) + ) + } + + override fun applyEdit(params: ApplyWorkspaceEditParams): CompletableFuture = + CompletableFuture.supplyAsync( + { + try { + LspEditorUtil.applyWorkspaceEdit(project, params.edit) + ApplyWorkspaceEditResponse(true) + } catch (e: Exception) { + LOG.warn(e) { "Failed to apply workspace edit" } + ApplyWorkspaceEditResponse(false) + } + }, + ApplicationManager.getApplication()::invokeLater + ) + + private fun refreshVfs(path: String) { + val currPath = Paths.get(path) + if (currPath.startsWith(localHistoryPath)) return + try { + ApplicationManager.getApplication().executeOnPooledThread { + VfsUtil.markDirtyAndRefresh(false, true, true, currPath.toFile()) + } + } catch (e: Exception) { + LOG.warn(e) { "Could not refresh file" } + } + } + + companion object { + val localHistoryPath = Paths.get( + UserHomeDirectoryUtils.userHomeDirectory(), + ".aws", + "amazonq", + "history" + ) + private val LOG = getLogger() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt new file mode 100644 index 00000000000..2d6494f3dd6 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -0,0 +1,260 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileDocumentManagerListener +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorManager +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 +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.eclipse.lsp4j.DidChangeTextDocumentParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DidSaveTextDocumentParams +import org.eclipse.lsp4j.TextDocumentContentChangeEvent +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ACTIVE_EDITOR_CHANGED_NOTIFICATION +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.getCursorState +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString + +class TextDocumentServiceHandler( + private val project: Project, + private val cs: CoroutineScope, +) : FileDocumentManagerListener, + FileEditorManagerListener, + BulkFileListener, + DocumentListener, + Disposable { + + init { + // didOpen & didClose events + project.messageBus.connect(this).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + this + ) + + // didChange events + project.messageBus.connect(this).subscribe( + VirtualFileManager.VFS_CHANGES, + this + ) + + // didSave events + project.messageBus.connect(this).subscribe( + FileDocumentManagerListener.TOPIC, + this + ) + + // open files on startup + cs.launch { + val fileEditorManager = FileEditorManager.getInstance(project) + fileEditorManager.selectedFiles.forEach { file -> + handleFileOpened(file) + } + } + } + + private fun handleFileOpened(file: VirtualFile) { + if (file.getUserData(KEY_REAL_TIME_EDIT_LISTENER) == null) { + val listener = object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + realTimeEdit(event) + } + } + ApplicationManager.getApplication().runReadAction { + FileDocumentManager.getInstance().getDocument(file)?.addDocumentListener(listener) + } + 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( + DidOpenTextDocumentParams().apply { + textDocument = TextDocumentItem().apply { + this.uri = uri + text = file.inputStream.readAllBytes().decodeToString() + languageId = file.fileType.name.lowercase() + version = file.modificationStamp.toInt() + } + } + ) + } + } + } + } + + override fun beforeDocumentSaving(document: Document) { + trySendIfValid { languageServer -> + val file = FileDocumentManager.getInstance().getFile(document) ?: return@trySendIfValid + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didSave( + DidSaveTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + // TODO: should respect `textDocumentSync.save.includeText` server capability config + text = document.text + } + ) + } + } + } + + override fun after(events: MutableList) { + events.filterIsInstance().forEach { event -> + val document = FileDocumentManager.getInstance().getCachedDocument(event.file) ?: return@forEach + + handleFileOpened(event.file) + trySendIfValid { languageServer -> + toUriString(event.file)?.let { uri -> + languageServer.textDocumentService.didChange( + DidChangeTextDocumentParams().apply { + textDocument = VersionedTextDocumentIdentifier().apply { + this.uri = uri + version = document.modificationStamp.toInt() + } + contentChanges = listOf( + TextDocumentContentChangeEvent().apply { + text = document.text + } + ) + } + ) + } + } + } + } + + override fun fileOpened( + source: FileEditorManager, + file: VirtualFile, + ) { + handleFileOpened(file) + } + + override fun fileClosed( + source: FileEditorManager, + file: VirtualFile, + ) { + val listener = file.getUserData(KEY_REAL_TIME_EDIT_LISTENER) + if (listener != null) { + tryOrNull { FileDocumentManager.getInstance().getDocument(file)?.removeDocumentListener(listener) } + file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, null) + + trySendIfValid { languageServer -> + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + } + ) + } + } + } + } + + override fun selectionChanged(event: FileEditorManagerEvent) { + handleActiveEditorChange(event.newEditor) + } + + private fun handleActiveEditorChange(fileEditor: FileEditor?) { + val editor = (fileEditor as? TextEditor)?.editor ?: return + val virtualFile = editor.virtualFile ?: return // Return early if no file + handleFileOpened(virtualFile) + + // Extract text editor if it's a TextEditor, otherwise null + val textDocumentIdentifier = TextDocumentIdentifier(toUriString(virtualFile)) + val cursorState = getCursorState(editor) + + val params = mapOf( + "textDocument" to textDocumentIdentifier, + "cursorState" to cursorState + ) + + // Send notification to the language server + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { _ -> + rawEndpoint.notify(ACTIVE_EDITOR_CHANGED_NOTIFICATION, params) + } + } + } + + private fun realTimeEdit(event: DocumentEvent) { + trySendIfValid { languageServer -> + val vFile = FileDocumentManager.getInstance().getFile(event.document) ?: return@trySendIfValid + toUriString(vFile)?.let { uri -> + languageServer.textDocumentService.didChange( + DidChangeTextDocumentParams().apply { + textDocument = VersionedTextDocumentIdentifier().apply { + this.uri = uri + version = event.document.modificationStamp.toInt() + } + contentChanges = listOf( + TextDocumentContentChangeEvent().apply { + text = event.document.text + } + ) + } + ) + } + } + // Process document changes here + } + + override fun dispose() { + } + + private fun trySendIfValid(runnable: (AmazonQLanguageServer) -> Unit) { + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> + try { + runnable(languageServer) + } catch (e: Exception) { + LOG.warn { "Invalid document: $e" } + } + } + } + } + + companion object { + private val KEY_REAL_TIME_EDIT_LISTENER = Key.create("amazonq.textdocument.realtimeedit.listener") + private val LOG = getLogger() + } +} From 0d79bd66406dfea0f60a9f32f1bbdf23a5c761dd Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Wed, 12 Nov 2025 22:59:49 -0800 Subject: [PATCH 16/35] Version segregate jetbrains-ultimate files for 2025.3 - CodeCatalystGatewayClientCustomizer: GatewayClientCustomizationProvider API removed - DevEnvStatusWatcher: UnattendedStatusUtil API removed, use fallback values - GoHelper: Go debugger APIs changed, throw UnsupportedOperationException - NodeJsDebugSupport: NodeJS debugger APIs changed, stub implementation src-242-252: Original working implementations src-253+: Stubbed/disabled implementations with TODOs --- .../CodeCatalystGatewayClientCustomizer.kt | 0 .../remoteDev/caws/DevEnvStatusWatcher.kt | 0 .../jetbrains/services/lambda/go/GoHelper.kt | 0 .../lambda/nodejs/NodeJsDebugSupport.kt | 0 .../CodeCatalystGatewayClientCustomizer.kt | 25 ++++ .../remoteDev/caws/DevEnvStatusWatcher.kt | 140 ++++++++++++++++++ .../jetbrains/services/lambda/go/GoHelper.kt | 74 +++++++++ .../lambda/nodejs/NodeJsDebugSupport.kt | 128 ++++++++++++++++ 8 files changed, 367 insertions(+) rename plugins/toolkit/jetbrains-ultimate/{src => src-242-252}/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt (100%) rename plugins/toolkit/jetbrains-ultimate/{src => src-242-252}/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt (100%) rename plugins/toolkit/jetbrains-ultimate/{src => src-242-252}/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt (100%) rename plugins/toolkit/jetbrains-ultimate/{src => src-242-252}/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt (100%) create mode 100644 plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt create mode 100644 plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt create mode 100644 plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt create mode 100644 plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt new file mode 100644 index 00000000000..4956b334ed1 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt @@ -0,0 +1,25 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.remoteDev.caws + +// TODO: GatewayClientCustomizationProvider removed in 2025.3 - investigate new Gateway customization APIs +/* +import com.intellij.openapi.extensions.ExtensionNotApplicableException +import com.jetbrains.rdserver.unattendedHost.customization.controlCenter.GatewayClientCustomizationProvider +import icons.AwsIcons +import software.aws.toolkits.jetbrains.utils.isCodeCatalystDevEnv +import software.aws.toolkits.resources.message + +class CodeCatalystGatewayClientCustomizer : GatewayClientCustomizationProvider { + init { + if (!isCodeCatalystDevEnv()) { + throw ExtensionNotApplicableException.create() + } + } + + override fun getIcon() = AwsIcons.Logos.AWS_SMILE_SMALL + + override fun getTitle() = message("caws.gateway.title") +} +*/ diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt new file mode 100644 index 00000000000..4e473f5052e --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt @@ -0,0 +1,140 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.remoteDev.caws + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import com.intellij.openapi.ui.MessageDialogBuilder +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +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.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.credentials.sono.CodeCatalystCredentialManager +import software.aws.toolkits.jetbrains.services.caws.CawsConstants +import software.aws.toolkits.jetbrains.services.caws.envclient.CawsEnvironmentClient +import software.aws.toolkits.jetbrains.services.caws.envclient.models.UpdateActivityRequest +import software.aws.toolkits.jetbrains.utils.isCodeCatalystDevEnv +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import java.time.Instant +import java.time.temporal.ChronoUnit + +class DevEnvStatusWatcher : StartupActivity { + + companion object { + private val LOG = getLogger() + } + + override fun runActivity(project: Project) { + if (!isCodeCatalystDevEnv()) { + return + } + val connection = CodeCatalystCredentialManager.getInstance(project).getConnectionSettings() + ?: error("Failed to fetch connection settings from Dev Environment") + val envId = System.getenv(CawsConstants.CAWS_ENV_ID_VAR) ?: error("envId env var null") + val org = System.getenv(CawsConstants.CAWS_ENV_ORG_NAME_VAR) ?: error("space env var null") + val projectName = System.getenv(CawsConstants.CAWS_ENV_PROJECT_NAME_VAR) ?: error("project env var null") + val client = connection.awsClient() + val coroutineScope = projectCoroutineScope(project) + coroutineScope.launch(getCoroutineBgContext()) { + val initialEnv = client.getDevEnvironment { + it.id(envId) + it.spaceName(org) + it.projectName(projectName) + } + val inactivityTimeout = initialEnv.inactivityTimeoutMinutes() + if (inactivityTimeout == 0) { + LOG.info { "Dev environment inactivity timeout is 0, not monitoring" } + return@launch + } + val inactivityTimeoutInSeconds = inactivityTimeout * 60 + + // TODO: Re-enable when Gateway APIs are available in 2025.3 + // val jbActivityStatus = GatewayConnectionUtil.getInstance().getSecondsSinceLastControllerActivity() + val jbActivityStatus = 0L // Temporary fallback + notifyBackendOfActivity((getActivityTime(jbActivityStatus).toString())) + var secondsSinceLastControllerActivity = jbActivityStatus + + while (true) { + val response = checkHeartbeat(secondsSinceLastControllerActivity, inactivityTimeoutInSeconds, project) + if (response.first) return@launch + delay(30000) + secondsSinceLastControllerActivity = response.second + } + } + } + + // This function returns a Pair The first value is a boolean indicating if the API returned the last recorded activity. + // If inactivity tracking is disabled or if the value returned by the API is unparseable, the heartbeat is not sent + // The second value indicates the seconds since last activity as recorded by JB in the most recent run + fun checkHeartbeat( + secondsSinceLastControllerActivity: Long, + inactivityTimeoutInSeconds: Int, + project: Project, + ): Pair { + val lastActivityTime = getJbRecordedActivity() + + if (lastActivityTime < secondsSinceLastControllerActivity) { + // update the API in case of any activity + notifyBackendOfActivity((getActivityTime(lastActivityTime).toString())) + } + + val lastRecordedActivityTime = getLastRecordedApiActivity() + if (lastRecordedActivityTime == null) { + LOG.error { "Couldn't retrieve last recorded activity from API" } + return Pair(true, lastActivityTime) + } + val durationRecordedSinceLastActivity = Instant.now().toEpochMilli().minus(lastRecordedActivityTime.toLong()) + val secondsRecordedSinceLastActivity = durationRecordedSinceLastActivity / 1000 + + if (secondsRecordedSinceLastActivity >= (inactivityTimeoutInSeconds - 300)) { + try { + val inactivityDurationInMinutes = secondsRecordedSinceLastActivity / 60 + val ans = runBlocking { + val continueWorking = withContext(getCoroutineUiContext()) { + return@withContext MessageDialogBuilder.okCancel( + message("caws.devenv.continue.working.after.timeout.title"), + message("caws.devenv.continue.working.after.timeout", inactivityDurationInMinutes) + ).ask(project) + } + return@runBlocking continueWorking + } + + if (ans) { + notifyBackendOfActivity(getActivityTime().toString()) + } + } catch (e: Exception) { + val preMessage = "Error while checking if Dev Environment should continue working" + LOG.error(e) { preMessage } + notifyError(preMessage, e.message.toString()) + } + } + return Pair(false, lastActivityTime) + } + + fun getLastRecordedApiActivity(): String? = CawsEnvironmentClient.getInstance().getActivity()?.timestamp + + // TODO: Re-enable when Gateway APIs are available in 2025.3 + // Original: GatewayConnectionUtil.getInstance().getSecondsSinceLastControllerActivity() + private val fallbackActivityTime = 0L + + fun getJbRecordedActivity(): Long = fallbackActivityTime + + fun notifyBackendOfActivity(timestamp: String = Instant.now().toEpochMilli().toString()) { + val request = UpdateActivityRequest( + timestamp = timestamp + ) + CawsEnvironmentClient.getInstance().putActivityTimestamp(request) + } + + private fun getActivityTime(secondsSinceLastActivity: Long = 0): Long = Instant.now().minus(secondsSinceLastActivity, ChronoUnit.SECONDS).toEpochMilli() +} diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt new file mode 100644 index 00000000000..33ab5f5745e --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt @@ -0,0 +1,74 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.go + +// TODO: Re-enable when Go plugin APIs are available in 2025.3 +// import com.goide.dlv.DlvDebugProcessUtil +import com.goide.execution.GoRunUtil +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.xdebugger.XDebugProcess +import com.intellij.xdebugger.XDebugProcessStarter +import com.intellij.xdebugger.XDebugSession +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import java.nio.file.Files + +/** + * "Light" ides like Goland do not rely on marking folders as source root, so infer it based on + * the go.mod file. This function is based off of the similar PackageJsonUtil#findUpPackageJson + * + * @throws IllegalStateException If the contentRoot cannot be located + */ +fun inferSourceRoot(project: Project, virtualFile: VirtualFile): VirtualFile? { + val projectFileIndex = ProjectFileIndex.getInstance(project) + val contentRoot = runReadAction { + projectFileIndex.getContentRootForFile(virtualFile) + } + + return contentRoot?.let { root -> + var file = virtualFile.parent + while (file != null) { + if ((file.isDirectory && file.children.any { !it.isDirectory && it.name == "go.mod" })) { + return file + } + // If we go up to the root and it's still not found, stop going up and mark source root as + // not found, since it will fail to build + if (file == root) { + return null + } + file = file.parent + } + return null + } +} + +object GoDebugHelper { + // TODO see https://youtrack.jetbrains.com/issue/GO-10775 for "Debugger disconnected unexpectedly" when the lambda finishes + fun createGoDebugProcess( + @Suppress("UNUSED_PARAMETER") debugHost: String, + @Suppress("UNUSED_PARAMETER") debugPorts: List, + @Suppress("UNUSED_PARAMETER") context: Context, + ): XDebugProcessStarter = object : XDebugProcessStarter() { + override fun start(session: XDebugSession): XDebugProcess { + // TODO: Re-enable when Go plugin APIs are available in 2025.3 + // val process = DlvDebugProcessUtil.createDlvDebugProcess(session, DlvDisconnectOption.KILL, null, true) + throw UnsupportedOperationException("Go debugging temporarily disabled in 2025.3 - Go plugin APIs moved") + } + } + + fun copyDlv(): String { + // This can take a target platform, but that pulls directly from GOOS, so we have to walk back up the file tree + // either way. Goland comes with mac/window/linux dlv since it supports remote debugging, so it is always safe to + // pull the linux one + val dlvFolder = GoRunUtil.getBundledDlv(null)?.parentFile?.parentFile?.resolve("linux") + ?: throw IllegalStateException("Packaged Devle debugger is not found!") + val directory = Files.createTempDirectory("goDebugger") + Files.copy(dlvFolder.resolve("dlv").toPath(), directory.resolve("dlv")) + // Delve that comes packaged with the IDE does not have the executable flag set + directory.resolve("dlv").toFile().setExecutable(true) + return directory.toAbsolutePath().toString() + } +} diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt new file mode 100644 index 00000000000..51b36f27f65 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt @@ -0,0 +1,128 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.nodejs + +import com.google.common.collect.BiMap +import com.google.common.collect.HashBiMap +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.ui.ExecutionConsole +import com.intellij.javascript.debugger.LocalFileSystemFileFinder +import com.intellij.javascript.debugger.RemoteDebuggingFileFinder +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.xdebugger.XDebugProcess +import com.intellij.xdebugger.XDebugProcessStarter +import com.intellij.xdebugger.XDebugSession +import com.intellij.xdebugger.evaluation.XDebuggerEditorsProviderBase +import compat.com.intellij.lang.javascript.JavascriptLanguage +import org.jetbrains.io.LocalFileFinder +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.PathMapping +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ImageDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.RuntimeDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamRunningState +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import javax.swing.JComponent +import javax.swing.JLabel + +class NodeJsRuntimeDebugSupport : RuntimeDebugSupport { + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List, + ): XDebugProcessStarter = NodeJsDebugUtils.createDebugProcess(state, debugHost, debugPorts) +} + +abstract class NodeJsImageDebugSupport : ImageDebugSupport { + override fun supportsPathMappings(): Boolean = true + override val languageId = JavascriptLanguage.id + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List, + ): XDebugProcessStarter = NodeJsDebugUtils.createDebugProcess(state, debugHost, debugPorts) + + override fun containerEnvVars(debugPorts: List): Map = mapOf( + "NODE_OPTIONS" to "--inspect-brk=0.0.0.0:${debugPorts.first()} --max-http-header-size 81920" + ) +} + +class NodeJs16ImageDebug : NodeJsImageDebugSupport() { + override val id: String = LambdaRuntime.NODEJS16_X.toString() + override fun displayName() = LambdaRuntime.NODEJS16_X.toString().capitalize() +} + +class NodeJs18ImageDebug : NodeJsImageDebugSupport() { + override val id: String = LambdaRuntime.NODEJS18_X.toString() + override fun displayName() = LambdaRuntime.NODEJS18_X.toString().capitalize() +} + +class NodeJs20ImageDebug : NodeJsImageDebugSupport() { + override val id: String = LambdaRuntime.NODEJS20_X.toString() + override fun displayName() = LambdaRuntime.NODEJS20_X.toString().capitalize() +} + +object NodeJsDebugUtils { + private const val NODE_MODULES = "node_modules" + + // Noop editors provider for disabled NodeJS debugging in 2025.3 + private class NoopXDebuggerEditorsProvider : XDebuggerEditorsProviderBase() { + override fun getFileType(): FileType = PlainTextFileType.INSTANCE + override fun createExpressionCodeFragment(project: Project, text: String, context: PsiElement?, isPhysical: Boolean): PsiFile? = null + } + + fun createDebugProcess( + state: SamRunningState, + @Suppress("UNUSED_PARAMETER") debugHost: String, + @Suppress("UNUSED_PARAMETER") debugPorts: List, + ): XDebugProcessStarter = object : XDebugProcessStarter() { + override fun start(session: XDebugSession): XDebugProcess { + val mappings = createBiMapMappings(state.pathMappings) + + @Suppress("UNUSED_VARIABLE") + val fileFinder = RemoteDebuggingFileFinder(mappings, LocalFileSystemFileFinder()) + + // STUB IMPLEMENTATION: NodeJS debugging temporarily disabled + return object : XDebugProcess(session) { + override fun getEditorsProvider() = NoopXDebuggerEditorsProvider() + override fun doGetProcessHandler() = null + override fun createConsole() = object : ExecutionConsole { + override fun getComponent(): JComponent = JLabel("NodeJS debugging disabled in 2025.3") + override fun getPreferredFocusableComponent(): JComponent? = null + override fun dispose() {} + } + } + } + } + + /** + * Convert [PathMapping] to NodeJs debugger path mapping format. + * + * Docker uses the same project structure for dependencies in the folder node_modules. We map the source code and + * the dependencies in node_modules folder separately as the node_modules might not exist in the local project. + */ + private fun createBiMapMappings(pathMapping: List): BiMap { + val mappings = HashBiMap.create(pathMapping.size) + + listOf(".", NODE_MODULES).forEach { subPath -> + pathMapping.forEach { + val remotePath = FileUtil.toCanonicalPath("${it.remoteRoot}/$subPath") + LocalFileFinder.findFile("${it.localRoot}/$subPath")?.let { localFile -> + mappings.putIfAbsent("file://$remotePath", localFile) + } + } + } + + return mappings + } +} From 097b3d0531e8e63e32b41e05738cf72253e1f780 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 01:22:32 -0800 Subject: [PATCH 17/35] Version segregate CodeWhisperer services and test files for 2025.3 - CodeWhispererService/ServiceNew: VirtualFile nullability fixes - ArtifactManagerTest: ProjectExtension -> HeavyPlatformTestCase - DefaultAuthCredentialsServiceTest: ProjectExtension -> HeavyPlatformTestCase - RedshiftUtilsTest: ProjectExtension -> HeavyPlatformTestCase src-242-252/tst-242-252: Original JUnit5 implementations src-253+/tst-253+: Fixed implementations with null checks and JUnit4 --- .../service/CodeWhispererService.kt | 0 .../service/CodeWhispererServiceNew.kt | 0 .../service/CodeWhispererService.kt | 655 ++++++++++++++++++ .../service/CodeWhispererServiceNew.kt | 130 ++++ .../lsp/artifacts/ArtifactManagerTest.kt | 0 .../auth/DefaultAuthCredentialsServiceTest.kt | 0 .../lsp/artifacts/ArtifactManagerTest.kt | 130 ++++ .../auth/DefaultAuthCredentialsServiceTest.kt | 262 +++++++ .../services/redshift/RedshiftUtilsTest.kt | 0 .../services/redshift/RedshiftUtilsTest.kt | 43 ++ 10 files changed, 1220 insertions(+) rename plugins/amazonq/codewhisperer/jetbrains-community/{src => src-242-252}/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt (100%) rename plugins/amazonq/codewhisperer/jetbrains-community/{src => src-242-252}/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt (100%) create mode 100644 plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt create mode 100644 plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt rename plugins/amazonq/shared/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt (100%) rename plugins/amazonq/shared/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt (100%) create mode 100644 plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt create mode 100644 plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt rename plugins/toolkit/jetbrains-ultimate/{tst => tst-242-252}/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt (100%) create mode 100644 plugins/toolkit/jetbrains-ultimate/tst-253+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt new file mode 100644 index 00000000000..eb1db27ff72 --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt @@ -0,0 +1,655 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.codeInsight.hint.HintManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.VisualPosition +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.util.Disposer +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.eclipse.lsp4j.DidChangeTextDocumentParams +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.TextDocumentContentChangeEvent +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier +import org.eclipse.lsp4j.jsonrpc.JsonRpcException +import org.eclipse.lsp4j.jsonrpc.messages.Either +import software.amazon.awssdk.core.exception.SdkServiceException +import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException +import software.aws.toolkits.core.utils.debug +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.coroutines.EDT +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.GetConfigurationFromServerParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionContext +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionListWithReferences +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionTriggerKind +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionWithReferencesParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManager +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.getCaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.WorkerContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CaretMovement +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeInsightsSettingsFacade +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getCompletionType +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyErrorCodeWhispererUsageLimit +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.promptReAuth +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileContextProvider +import software.aws.toolkits.jetbrains.utils.isInjectedText +import software.aws.toolkits.jetbrains.utils.isQExpired +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererTriggerType +import java.net.URI +import java.nio.file.Paths +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +@Service +class CodeWhispererService(private val cs: CoroutineScope) : Disposable { + private val codeInsightSettingsFacade = CodeInsightsSettingsFacade() + private var refreshFailure: Int = 0 + + init { + Disposer.register(this, codeInsightSettingsFacade) + } + + private var job: Job? = null + fun showRecommendationsInPopup( + editor: Editor, + triggerTypeInfo: TriggerTypeInfo, + latencyContext: LatencyContext, + ): Job? { + if (job == null || job?.isCompleted == true) { + job = cs.launch(getCoroutineBgContext()) { + doShowRecommendationsInPopup(editor, triggerTypeInfo, latencyContext) + } + } + + // did some wrangling, but compiler didn't believe this can't be null + return job + } + + private suspend fun doShowRecommendationsInPopup( + editor: Editor, + triggerTypeInfo: TriggerTypeInfo, + latencyContext: LatencyContext, + ) { + val project = editor.project ?: return + if (!isCodeWhispererEnabled(project)) return + + // try to refresh automatically if possible, otherwise ask user to login again + if (isQExpired(project)) { + // consider changing to only running once a ~minute since this is relatively expensive + // say the connection is un-refreshable if refresh fails for 3 times + val shouldReauth = if (refreshFailure < MAX_REFRESH_ATTEMPT) { + val attempt = withContext(getCoroutineBgContext()) { + promptReAuth(project) + } + + if (!attempt) { + refreshFailure++ + } + + attempt + } else { + true + } + + if (shouldReauth) { + return + } + } + + val psiFile = runReadAction { PsiDocumentManager.getInstance(project).getPsiFile(editor.document) } + + if (psiFile == null) { + LOG.debug { "No PSI file for the current document" } + if (triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + showCodeWhispererInfoHint(editor, message("codewhisperer.trigger.document.unsupported")) + } + return + } + val isInjectedFile = runReadAction { psiFile.isInjectedText() } + if (isInjectedFile) return + + val requestContext = try { + getRequestContext(triggerTypeInfo, editor, project, psiFile, latencyContext) + } catch (e: Exception) { + LOG.debug { e.message.toString() } + return + } + + // TODO flare: since IDE local language check got removed, flare needs to implement json aws template support only + + LOG.debug { + "Calling CodeWhisperer service, trigger type: ${triggerTypeInfo.triggerType}" + + if (triggerTypeInfo.triggerType == CodewhispererTriggerType.AutoTrigger) { + ", auto-trigger type: ${triggerTypeInfo.automatedTriggerType}" + } else { + "" + } + } + + val invocationStatus = CodeWhispererInvocationStatus.getInstance() + if (invocationStatus.checkExistingInvocationAndSet()) { + return + } + + invokeCodeWhispererInBackground(requestContext) + } + + internal suspend fun invokeCodeWhispererInBackground(requestContext: RequestContext): Job { + val popup = withContext(EDT) { + CodeWhispererPopupManager.getInstance().initPopup().also { + Disposer.register(it) { CodeWhispererInvocationStatus.getInstance().finishInvocation() } + } + } + + var states: InvocationContext? = null + + val job = cs.launch { + try { + var startTime = System.nanoTime() + CodeWhispererInvocationStatus.getInstance().setInvocationStart() + var nextToken: Either? = null + do { + val result = AmazonQLspService.executeAsyncIfRunning(requestContext.project) { server -> + val params = createInlineCompletionParams(requestContext.editor, requestContext.triggerTypeInfo, nextToken) + server.inlineCompletionWithReferences(params) + } + val completion = result?.await() + if (completion == null) { + // no result / not running + CodeWhispererInvocationStatus.getInstance().finishInvocation() + break + } + + nextToken = completion.partialResultToken + val endTime = System.nanoTime() + val latency = TimeUnit.NANOSECONDS.toMillis(endTime - startTime).toDouble() + startTime = endTime + val responseContext = ResponseContext(completion.sessionId) + logServiceInvocation(requestContext, responseContext, completion, latency, null) + + val workerContext = WorkerContext(requestContext, responseContext, completion, popup) + runInEdt { + states = processCodeWhispererUI(workerContext, states) + } + if (popup.isDisposed) { + LOG.debug { "Skipping sending remaining requests on CodeWhisperer session exit" } + return@launch + } + } while (nextToken != null && nextToken.left.isNotEmpty()) + } catch (e: Exception) { + // TODO flare: flare doesn't return exceptions + val sessionId = "" + val displayMessage: String + + if (e is JsonRpcException) { + // TODO: only log once to avoid auto-trigger spam? + LOG.debug(e) { + "Error talking to Q LSP server" + } + displayMessage = "Q LSP server failed to communicate, try restarting the current project." + } else { + val statusCode = if (e is SdkServiceException) e.statusCode() else 0 + displayMessage = + if (statusCode >= 500) { + message("codewhisperer.trigger.error.server_side") + } else { + message("codewhisperer.trigger.error.client_side") + } + if (statusCode < 500) { + LOG.debug(e) { "Error invoking CodeWhisperer service" } + } + } + val exceptionType = e::class.simpleName + val responseContext = ResponseContext(sessionId) + CodeWhispererInvocationStatus.getInstance().setInvocationSessionId(sessionId) + logServiceInvocation(requestContext, responseContext, null, null, exceptionType) + + if (e is ThrottlingException && + e.message == CodeWhispererConstants.THROTTLING_MESSAGE + ) { + CodeWhispererExplorerActionManager.getInstance().setSuspended(requestContext.project) + if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + notifyErrorCodeWhispererUsageLimit(requestContext.project) + } + } else { + if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + // We should only show error hint when CodeWhisperer popup is not visible, + // and make it silent if CodeWhisperer popup is showing. + if (!CodeWhispererInvocationStatus.getInstance().isDisplaySessionActive()) { + showCodeWhispererErrorHint(requestContext.editor, displayMessage) + } + } + } + CodeWhispererInvocationStatus.getInstance().finishInvocation() + runInEdt { + states?.let { + CodeWhispererPopupManager.getInstance().updatePopupPanel( + it, + CodeWhispererPopupManager.getInstance().sessionContext + ) + } + } + } finally { + CodeWhispererInvocationStatus.getInstance().setInvocationComplete() + } + } + + return job + } + + @RequiresEdt + private fun processCodeWhispererUI(workerContext: WorkerContext, currStates: InvocationContext?): InvocationContext? { + val requestContext = workerContext.requestContext + val responseContext = workerContext.responseContext + val completions = workerContext.completions + val popup = workerContext.popup + + // At this point when we are in EDT, the state of the popup will be thread-safe + // across this thread execution, so if popup is disposed, we will stop here. + // This extra check is needed because there's a time between when we get the response and + // when we enter the EDT. + if (popup.isDisposed) { + LOG.debug { "Stop showing CodeWhisperer recommendations on CodeWhisperer session exit. session id: ${responseContext.sessionId}" } + return null + } + + if (requestContext.editor.isDisposed) { + LOG.debug { "Stop showing CodeWhisperer recommendations since editor is disposed. session id: ${responseContext.sessionId}" } + sendDiscardedUserDecisionEventForAll(requestContext.project, requestContext.latencyContext, responseContext.sessionId, completions) + CodeWhispererPopupManager.getInstance().cancelPopup(popup) + return null + } + + if (completions.partialResultToken?.left.isNullOrEmpty()) { + CodeWhispererInvocationStatus.getInstance().finishInvocation() + } + + val caretMovement = CodeWhispererEditorManager.getInstance().getCaretMovement( + requestContext.editor, + requestContext.caretPosition + ) + val isPopupShowing: Boolean + val nextStates: InvocationContext? + if (currStates == null) { + // first response + nextStates = initStates(requestContext, responseContext, completions, caretMovement, popup) + isPopupShowing = false + + // receiving a null state means caret has moved backward or there's a conflict with + // Intellisense popup, so we are going to cancel the job + if (nextStates == null) { + LOG.debug { "Cancelling popup and exiting CodeWhisperer session. session id: ${responseContext.sessionId}" } + sendDiscardedUserDecisionEventForAll(requestContext.project, requestContext.latencyContext, responseContext.sessionId, completions) + CodeWhispererPopupManager.getInstance().cancelPopup(popup) + return null + } + } else { + // subsequent responses + nextStates = updateStates(currStates, completions) + isPopupShowing = checkRecommendationsValidity(currStates, false) + } + + val hasAtLeastOneValid = checkRecommendationsValidity(nextStates, completions.partialResultToken == null) + + // If there are no recommendations at all in this session, we need to manually send the user decision event here + // since it won't be sent automatically later + if (!hasAtLeastOneValid) { + if (completions.partialResultToken?.left.isNullOrEmpty()) { + LOG.debug { "None of the recommendations are valid, exiting CodeWhisperer session" } + CodeWhispererPopupManager.getInstance().cancelPopup(popup) + return null + } + } else { + updateCodeWhisperer(nextStates, isPopupShowing) + } + return nextStates + } + + private fun initStates( + requestContext: RequestContext, + responseContext: ResponseContext, + completions: InlineCompletionListWithReferences, + caretMovement: CaretMovement, + popup: JBPopup, + ): InvocationContext? { + val visualPosition = requestContext.editor.caretModel.visualPosition + + if (CodeWhispererPopupManager.getInstance().hasConflictingPopups(requestContext.editor)) { + LOG.debug { "Detect conflicting popup window with CodeWhisperer popup, not showing CodeWhisperer popup" } + return null + } + + if (caretMovement == CaretMovement.MOVE_BACKWARD) { + LOG.debug { "Caret moved backward, discarding all of the recommendations. Session Id: ${completions.sessionId}" } + return null + } + val userInput = + if (caretMovement == CaretMovement.NO_CHANGE) { + LOG.debug { "Caret position not changed since invocation. Session Id: ${completions.sessionId}" } + "" + } else { + LOG.debug { "Caret position moved forward since invocation. Session Id: ${completions.sessionId}" } + CodeWhispererEditorManager.getInstance().getUserInputSinceInvocation( + requestContext.editor, + requestContext.caretPosition.offset + ) + } + val detailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( + userInput, + completions, + ) + val recommendationContext = RecommendationContext(detailContexts, userInput, visualPosition) + return buildInvocationContext(requestContext, responseContext, recommendationContext, popup) + } + + private fun updateStates( + states: InvocationContext, + completions: InlineCompletionListWithReferences, + ): InvocationContext { + val recommendationContext = states.recommendationContext + val details = recommendationContext.details + val newDetailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( + recommendationContext.userInput, + completions, + ) + Disposer.dispose(states) + + val updatedStates = states.copy( + recommendationContext = recommendationContext.copy(details = details + newDetailContexts) + ) + Disposer.register(states.popup, updatedStates) + CodeWhispererPopupManager.getInstance().initPopupListener(updatedStates) + return updatedStates + } + + private fun checkRecommendationsValidity(states: InvocationContext, showHint: Boolean): Boolean { + val details = states.recommendationContext.details + + // set to true when at least one is not discarded or empty + val hasAtLeastOneValid = details.any { !it.isDiscarded && it.completion.insertText.isNotEmpty() } + + if (!hasAtLeastOneValid && showHint && states.requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + showCodeWhispererInfoHint( + states.requestContext.editor, + message("codewhisperer.popup.no_recommendations") + ) + } + return hasAtLeastOneValid + } + + private fun updateCodeWhisperer(states: InvocationContext, recommendationAdded: Boolean) { + CodeWhispererPopupManager.getInstance().changeStates(states, 0, recommendationAdded) + } + + private fun sendDiscardedUserDecisionEventForAll( + project: Project, + latencyContext: LatencyContext, + sessionId: String, + completions: InlineCompletionListWithReferences, + ) { + val detailContexts = completions.items.map { + DetailContext(it.itemId, it, true, getCompletionType(it)) + } + val recommendationContext = RecommendationContext(detailContexts, "", VisualPosition(0, 0)) + CodeWhispererTelemetryService.getInstance().sendUserTriggerDecisionEvent(project, latencyContext, sessionId, recommendationContext) + } + + suspend fun getRequestContext( + triggerTypeInfo: TriggerTypeInfo, + editor: Editor, + project: Project, + psiFile: PsiFile, + latencyContext: LatencyContext, + ): RequestContext { + // 1. file context + val fileContext: FileContextInfo = runReadAction { FileContextProvider.getInstance(project).extractFileContext(editor, psiFile) } + + // 3. caret position + val caretPosition = runReadAction { getCaretPosition(editor) } + + // 4. connection + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + + // 5. customization + val customizationArn = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn + + var workspaceId: String? = null + try { + val workspacesInfos = getWorkspaceIds(project).get().workspaces + for (workspaceInfo in workspacesInfos) { + val workspaceRootPath = Paths.get(URI(workspaceInfo.workspaceRoot)).toString() + if (psiFile.virtualFile.path.startsWith(workspaceRootPath)) { + workspaceId = workspaceInfo.workspaceId + LOG.info { "Found workspaceId from LSP '$workspaceId'" } + break + } + } + } catch (e: Exception) { + LOG.warn { "Cannot get workspaceId from LSP'$e'" } + } + return RequestContext( + project, + editor, + triggerTypeInfo, + caretPosition, + fileContext, + connection, + latencyContext, + customizationArn, + workspaceId, + ) + } + + suspend fun getWorkspaceIds(project: Project): CompletableFuture { + val payload = GetConfigurationFromServerParams( + section = "aws.q.workspaceContext" + ) + return AmazonQLspService.executeAsyncIfRunning(project) { server -> + server.getConfigurationFromServer(payload) + } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) + } + + private fun buildInvocationContext( + requestContext: RequestContext, + responseContext: ResponseContext, + recommendationContext: RecommendationContext, + popup: JBPopup, + ): InvocationContext { + addPopupChildDisposables(popup) + // Creating a disposable for managing all listeners lifecycle attached to the popup. + // previously(before pagination) we use popup as the parent disposable. + // After pagination, listeners need to be updated as states are updated, for the same popup, + // so disposable chain becomes popup -> disposable -> listeners updates, and disposable gets replaced on every + // state update. + val states = InvocationContext(requestContext, responseContext, recommendationContext, popup) + Disposer.register(popup, states) + CodeWhispererPopupManager.getInstance().initPopupListener(states) + return states + } + + fun createInlineCompletionParams( + editor: Editor, + triggerTypeInfo: TriggerTypeInfo, + nextToken: Either?, + ): InlineCompletionWithReferencesParams = + ReadAction.compute { + InlineCompletionWithReferencesParams( + context = InlineCompletionContext( + // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind + triggerKind = when (triggerTypeInfo.triggerType) { + CodewhispererTriggerType.OnDemand -> InlineCompletionTriggerKind.Invoke + CodewhispererTriggerType.AutoTrigger -> InlineCompletionTriggerKind.Automatic + else -> InlineCompletionTriggerKind.Invoke + } + ), + documentChangeParams = + if (triggerTypeInfo.automatedTriggerType == CodeWhispererAutomatedTriggerType.IntelliSense()) { + DidChangeTextDocumentParams( + VersionedTextDocumentIdentifier(), + listOf( + TextDocumentContentChangeEvent( + null, + CodeWhispererAutomatedTriggerType.IntelliSense().toString() + ) + ), + ) + } else { + null + }, + openTabFilepaths = editor.project?.let { project -> + com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) + .openFiles.mapNotNull { toUriString(it) } + }.orEmpty(), + ).apply { + textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile ?: return@compute null)) + position = Position( + editor.caretModel.primaryCaret.logicalPosition.line, + editor.caretModel.primaryCaret.logicalPosition.column + ) + if (nextToken != null) { + partialResultToken = nextToken + } + } + } + + private fun addPopupChildDisposables(popup: JBPopup) { + codeInsightSettingsFacade.disableCodeInsightUntil(popup) + + Disposer.register(popup) { + CodeWhispererPopupManager.getInstance().reset() + } + } + + private fun logServiceInvocation( + requestContext: RequestContext, + responseContext: ResponseContext, + completion: InlineCompletionListWithReferences?, + latency: Double?, + exceptionType: String?, + ) { + val recommendationLogs = completion?.items?.map { it.insertText.trimEnd() } + ?.reduceIndexedOrNull { index, acc, recommendation -> "$acc\n[${index + 1}]\n$recommendation" } + LOG.info { + "SessionId: ${responseContext.sessionId}, " + + "Jetbrains IDE: ${ApplicationInfo.getInstance().fullApplicationName}, " + + "IDE version: ${ApplicationInfo.getInstance().apiVersion}, " + + "Filename: ${requestContext.fileContextInfo.filename}, " + + "Left context of current line: ${requestContext.fileContextInfo.caretContext.leftContextOnCurrentLine}, " + + "Cursor line: ${requestContext.caretPosition.line}, " + + "Caret offset: ${requestContext.caretPosition.offset}, " + + (latency?.let { "Latency: $latency, " } ?: "") + + (exceptionType?.let { "Exception Type: $it, " } ?: "") + + "Recommendations: \n${recommendationLogs ?: "None"}" + } + } + + fun canDoInvocation(editor: Editor, type: CodewhispererTriggerType): Boolean { + editor.project?.let { + if (!isCodeWhispererEnabled(it)) { + return false + } + } + + if (type == CodewhispererTriggerType.AutoTrigger && !CodeWhispererExplorerActionManager.getInstance().isAutoEnabled()) { + LOG.debug { "CodeWhisperer auto-trigger is disabled, not invoking service" } + return false + } + + if (CodeWhispererPopupManager.getInstance().hasConflictingPopups(editor)) { + LOG.debug { "Find other active popup windows before triggering CodeWhisperer, not invoking service" } + return false + } + + if (CodeWhispererInvocationStatus.getInstance().isDisplaySessionActive()) { + LOG.debug { "Find an existing CodeWhisperer popup window before triggering CodeWhisperer, not invoking service" } + return false + } + return true + } + + private fun showCodeWhispererInfoHint(editor: Editor, message: String) { + runInEdt { + HintManager.getInstance().showInformationHint(editor, message, HintManager.UNDER) + } + } + + private fun showCodeWhispererErrorHint(editor: Editor, message: String) { + runInEdt { + HintManager.getInstance().showErrorHint(editor, message, HintManager.UNDER) + } + } + + override fun dispose() {} + + companion object { + private val LOG = getLogger() + private const val MAX_REFRESH_ATTEMPT = 3 + + fun getInstance(): CodeWhispererService = service() + const val KET_SESSION_ID = "x-amzn-SessionId" + private var reAuthPromptShown = false + + fun markReAuthPromptShown() { + reAuthPromptShown = true + } + + fun hasReAuthPromptBeenShown() = reAuthPromptShown + } +} + +data class RequestContext( + val project: Project, + val editor: Editor, + val triggerTypeInfo: TriggerTypeInfo, + val caretPosition: CaretPosition, + val fileContextInfo: FileContextInfo, + val connection: ToolkitConnection?, + val latencyContext: LatencyContext, + val customizationArn: String?, + val workspaceId: String?, +) + +data class ResponseContext( + val sessionId: String, +) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt new file mode 100644 index 00000000000..5a88002ef5b --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt @@ -0,0 +1,130 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.extensions.PluginId +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.util.text.SemVer +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange +import java.nio.file.Files +import java.nio.file.Path + +class ArtifactManagerTest : HeavyPlatformTestCase() { + private lateinit var tempDir: Path + private lateinit var artifactHelper: ArtifactHelper + private lateinit var artifactManager: ArtifactManager + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifestVersionRanges: SupportedManifestVersionRange + + override fun setUp() { + super.setUp() + tempDir = Files.createTempDirectory("artifact-test") + artifactHelper = spyk(ArtifactHelper(tempDir, 3)) + manifestFetcher = spyk(ManifestFetcher()) + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper)) + } + + fun testFetchArtifactFetcherReturnsBundledIfManifestIsNull() = runTest { + every { manifestFetcher.fetch() }.returns(null) + + assertThat(artifactManager.fetchArtifact(project)) + .isEqualTo( + PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") + ) + } + + fun testFetchArtifactDoesNotHaveAnyValidLspVersionsReturnsBundled() = runTest { + every { manifestFetcher.fetch() }.returns(Manifest()) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList()) + ) + + assertThat(artifactManager.fetchArtifact(project)) + .isEqualTo( + PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") + ) + } + + fun testGetLSPVersionsFromManifestWithSpecifiedRangeExcludesEndMajorVersion() = runTest { + val newManifest = Manifest(versions = listOf(Version(serverVersion = "2.0.0"))) + val result = artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(newManifest) + assertThat(result.inRangeVersions).isEmpty() + } + + fun testFetchArtifactIfInRangeVersionsAreNotAvailableShouldFallbackToLocalLsp() = runTest { + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { manifestFetcher.fetch() }.returns(Manifest()) + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + artifactManager.fetchArtifact(project) + + verify(exactly = 1) { manifestFetcher.fetch() } + verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) } + } + + fun testFetchArtifactHaveValidVersionInLocalSystem() = runTest { + val target = VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(Version("1.0.0", targets = listOf(target))) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(false) + coEvery { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } returns tempDir + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + + artifactManager.fetchArtifact(project) + + coVerify(exactly = 1) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } + + fun testFetchArtifactDoesNotHaveValidVersionInLocalSystem() = runTest { + val target = VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(Version("1.0.0", targets = listOf(target))) + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(true) + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + artifactManager.fetchArtifact(project) + + coVerify(exactly = 0) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt rename to plugins/amazonq/shared/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt rename to plugins/amazonq/shared/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt diff --git a/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt new file mode 100644 index 00000000000..5a88002ef5b --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt @@ -0,0 +1,130 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.extensions.PluginId +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.util.text.SemVer +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange +import java.nio.file.Files +import java.nio.file.Path + +class ArtifactManagerTest : HeavyPlatformTestCase() { + private lateinit var tempDir: Path + private lateinit var artifactHelper: ArtifactHelper + private lateinit var artifactManager: ArtifactManager + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifestVersionRanges: SupportedManifestVersionRange + + override fun setUp() { + super.setUp() + tempDir = Files.createTempDirectory("artifact-test") + artifactHelper = spyk(ArtifactHelper(tempDir, 3)) + manifestFetcher = spyk(ManifestFetcher()) + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper)) + } + + fun testFetchArtifactFetcherReturnsBundledIfManifestIsNull() = runTest { + every { manifestFetcher.fetch() }.returns(null) + + assertThat(artifactManager.fetchArtifact(project)) + .isEqualTo( + PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") + ) + } + + fun testFetchArtifactDoesNotHaveAnyValidLspVersionsReturnsBundled() = runTest { + every { manifestFetcher.fetch() }.returns(Manifest()) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList()) + ) + + assertThat(artifactManager.fetchArtifact(project)) + .isEqualTo( + PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") + ) + } + + fun testGetLSPVersionsFromManifestWithSpecifiedRangeExcludesEndMajorVersion() = runTest { + val newManifest = Manifest(versions = listOf(Version(serverVersion = "2.0.0"))) + val result = artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(newManifest) + assertThat(result.inRangeVersions).isEmpty() + } + + fun testFetchArtifactIfInRangeVersionsAreNotAvailableShouldFallbackToLocalLsp() = runTest { + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { manifestFetcher.fetch() }.returns(Manifest()) + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + artifactManager.fetchArtifact(project) + + verify(exactly = 1) { manifestFetcher.fetch() } + verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) } + } + + fun testFetchArtifactHaveValidVersionInLocalSystem() = runTest { + val target = VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(Version("1.0.0", targets = listOf(target))) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(false) + coEvery { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } returns tempDir + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + + artifactManager.fetchArtifact(project) + + coVerify(exactly = 1) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } + + fun testFetchArtifactDoesNotHaveValidVersionInLocalSystem() = runTest { + val target = VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(Version("1.0.0", targets = listOf(target))) + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(true) + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + artifactManager.fetchArtifact(project) + + coVerify(exactly = 0) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt new file mode 100644 index 00000000000..b1946f9e556 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.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.amazonq.lsp.auth + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.project.Project +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.replaceService +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.sso.PKCEAuthorizationGrantToken +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.InteractiveBearerTokenProvider +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload +import software.aws.toolkits.jetbrains.utils.isQConnected +import software.aws.toolkits.jetbrains.utils.isQExpired +import java.time.Instant +import java.util.concurrent.CompletableFuture + +class DefaultAuthCredentialsServiceTest : HeavyPlatformTestCase() { + companion object { + private const val TEST_ACCESS_TOKEN = "test-access-token" + } + + private lateinit var spyProject: Project + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockEncryptionManager: JwtEncryptionManager + private lateinit var mockConnectionManager: ToolkitConnectionManager + private lateinit var mockConnection: AwsBearerTokenConnection + private lateinit var sut: DefaultAuthCredentialsService + + override fun setUp() { + super.setUp() + spyProject = spyk(project) + setupMockLspService() + setupMockMessageBus() + setupMockConnectionManager() + } + + private fun setupMockLspService() { + mockLanguageServer = mockk() + mockEncryptionManager = mockk { + every { encrypt(any()) } returns "mock-encrypted-data" + } + + val mockLspService = mockk() + coEvery { + mockLspService.executeIfRunning>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + every { + mockLanguageServer.updateTokenCredentials(any()) + } returns CompletableFuture.completedFuture(ResponseMessage()) + + every { + mockLanguageServer.deleteTokenCredentials() + } returns Unit + + every { + mockLanguageServer.updateConfiguration(any()) + } returns CompletableFuture.completedFuture(LspServerConfigurations(emptyList())) + + every { spyProject.getService(AmazonQLspService::class.java) } returns mockLspService + every { spyProject.serviceIfCreated() } returns mockLspService + } + + private fun setupMockMessageBus() { + val messageBus = mockk() + val mockConnection = mockk { + every { subscribe(any(), any()) } just runs + } + every { spyProject.messageBus } returns messageBus + every { messageBus.connect(any()) } returns mockConnection + } + + private fun setupMockConnectionManager(accessToken: String = TEST_ACCESS_TOKEN) { + mockConnection = createMockConnection(accessToken) + mockConnectionManager = mockk { + every { activeConnectionForFeature(any()) } returns mockConnection + every { connectionStateForFeature(any()) } returns BearerTokenAuthState.AUTHORIZED + } + spyProject.replaceService(ToolkitConnectionManager::class.java, mockConnectionManager, spyProject) + mockkStatic("software.aws.toolkits.jetbrains.utils.FunctionUtilsKt") + // these set so init doesn't always emit + every { isQConnected(any()) } returns false + every { isQExpired(any()) } returns true + } + + private fun createMockConnection( + accessToken: String, + connectionId: String = "test-connection-id", + ): AwsBearerTokenConnection = mockk { + every { id } returns connectionId + every { startUrl } returns "startUrl" + every { getConnectionSettings() } returns createMockTokenSettings(accessToken) + } + + private fun createMockTokenSettings(accessToken: String): TokenConnectionSettings { + val token = PKCEAuthorizationGrantToken( + issuerUrl = "https://example.com", + refreshToken = "refreshToken", + accessToken = accessToken, + expiresAt = Instant.MAX, + createdAt = Instant.now(), + region = "us-fake-1", + ) + + val tokenDelegate = mockk { + every { currentToken() } returns token + } + + val provider = mockk { + every { delegate } returns tokenDelegate + } + + return mockk { + every { tokenProvider } returns provider + } + } + + fun testActiveConnectionChangedUpdatesTokenWhenConnectionIdMatchesQConnection() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + val newConnection = createMockConnection("new-token", "connection-id") + every { mockConnection.id } returns "connection-id" + + sut.activeConnectionChanged(newConnection) + + advanceUntilIdle() + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testActiveConnectionChangedDoesNotUpdateTokenWhenConnectionIdDiffers() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + val newConnection = createMockConnection("new-token", "different-id") + every { mockConnection.id } returns "q-connection-id" + + sut.activeConnectionChanged(newConnection) + + advanceUntilIdle() + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testOnChangeUpdatesTokenWithNewConnection() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + setupMockConnectionManager("updated-token") + + sut.onProviderChange("providerId", listOf("new-scope")) + + advanceUntilIdle() + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testInitDoesNotUpdateTokenWhenQIsNotConnected() = runTest { + every { isQConnected(spyProject) } returns false + every { isQExpired(spyProject) } returns false + + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + advanceUntilIdle() + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testInitDoesNotUpdateTokenWhenQIsExpired() = runTest { + every { isQConnected(spyProject) } returns true + every { isQExpired(spyProject) } returns true + + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + advanceUntilIdle() + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testUpdateTokenCredentialsUnencryptedSuccess() = runTest { + val isEncrypted = false + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + sut.updateTokenCredentials(mockConnection, isEncrypted) + + advanceUntilIdle() + verify(exactly = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + "test-access-token", + ConnectionMetadata( + SsoProfileData("startUrl") + ), + isEncrypted + ) + ) + } + } + + fun testUpdateTokenCredentialsEncryptedSuccess() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + val encryptedToken = "encryptedToken" + val isEncrypted = true + + every { mockEncryptionManager.encrypt(any()) } returns encryptedToken + + sut.updateTokenCredentials(mockConnection, isEncrypted) + + advanceUntilIdle() + verify(atLeast = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + encryptedToken, + ConnectionMetadata( + SsoProfileData("startUrl") + ), + isEncrypted + ) + ) + } + } + + fun testDeleteTokenCredentialsSuccess() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + every { mockLanguageServer.deleteTokenCredentials() } returns Unit + + sut.deleteTokenCredentials() + + advanceUntilIdle() + verify(exactly = 1) { mockLanguageServer.deleteTokenCredentials() } + } + + fun testInitResultsInTokenUpdate() = runTest { + every { isQConnected(any()) } returns true + every { isQExpired(any()) } returns false + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + advanceUntilIdle() + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } +} diff --git a/plugins/toolkit/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt b/plugins/toolkit/jetbrains-ultimate/tst-242-252/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt rename to plugins/toolkit/jetbrains-ultimate/tst-242-252/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt diff --git a/plugins/toolkit/jetbrains-ultimate/tst-253+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt b/plugins/toolkit/jetbrains-ultimate/tst-253+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt new file mode 100644 index 00000000000..c879a3fe7d2 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/tst-253+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt @@ -0,0 +1,43 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift + +import com.intellij.testFramework.HeavyPlatformTestCase +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import software.amazon.awssdk.services.redshift.model.Cluster +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockResourceCacheExtension +import software.aws.toolkits.jetbrains.core.region.getDefaultRegion +import software.aws.toolkits.jetbrains.services.sts.StsResources + +class RedshiftUtilsTest : HeavyPlatformTestCase() { + private val resourceCache = MockResourceCacheExtension() + private lateinit var clusterId: String + private lateinit var accountId: String + private lateinit var mockCluster: Cluster + + override fun setUp() { + super.setUp() + clusterId = RuleUtils.randomName() + accountId = RuleUtils.randomName() + mockCluster = mock { + on { clusterIdentifier() } doReturn clusterId + } + } + + fun testAccountIdArn() { + val region = getDefaultRegion() + resourceCache.addEntry(project, StsResources.ACCOUNT, accountId) + val arn = project.clusterArn(mockCluster, region) + assertThat(arn).isEqualTo("arn:${region.partitionId}:redshift:${region.id}:$accountId:cluster:$clusterId") + } + + fun testNoAccountIdArn() { + val region = getDefaultRegion() + val arn = project.clusterArn(mockCluster, region) + assertThat(arn).isEqualTo("arn:${region.partitionId}:redshift:${region.id}::cluster:$clusterId") + } +} From f2b2d4cd25fdf515c67ef654d50d4c7e0dec2040 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 01:52:06 -0800 Subject: [PATCH 18/35] Fix CodeWhispererServiceNew.kt - replace test file with actual service file Previous commit accidentally copied test file instead of service file to src-253+ --- .../service/CodeWhispererServiceNew.kt | 725 +++++++++++++++--- 1 file changed, 630 insertions(+), 95 deletions(-) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt index 5a88002ef5b..389227d4967 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt @@ -1,130 +1,665 @@ -// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts - -import com.intellij.ide.plugins.PluginManagerCore -import com.intellij.openapi.extensions.PluginId -import com.intellij.testFramework.HeavyPlatformTestCase -import com.intellij.util.text.SemVer -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.just -import io.mockk.mockkStatic -import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.test.runTest -import org.assertj.core.api.Assertions.assertThat -import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange -import java.nio.file.Files -import java.nio.file.Path - -class ArtifactManagerTest : HeavyPlatformTestCase() { - private lateinit var tempDir: Path - private lateinit var artifactHelper: ArtifactHelper - private lateinit var artifactManager: ArtifactManager - private lateinit var manifestFetcher: ManifestFetcher - private lateinit var manifestVersionRanges: SupportedManifestVersionRange - - override fun setUp() { - super.setUp() - tempDir = Files.createTempDirectory("artifact-test") - artifactHelper = spyk(ArtifactHelper(tempDir, 3)) - manifestFetcher = spyk(ManifestFetcher()) - manifestVersionRanges = SupportedManifestVersionRange( - startVersion = SemVer("1.0.0", 1, 0, 0), - endVersion = SemVer("2.0.0", 2, 0, 0) - ) +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.codeInsight.hint.HintManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.VisualPosition +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.messages.Topic +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.jsonrpc.messages.Either +import software.amazon.awssdk.core.exception.SdkServiceException +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionContext +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionListWithReferences +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionTriggerKind +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionWithReferencesParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManagerNew +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.getCaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContextNew +import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.PreviewContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationContextNew +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContextNew +import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.WorkerContextNew +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManagerNew +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CaretMovement +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeInsightsSettingsFacade +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getCompletionType +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyErrorCodeWhispererUsageLimit +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.promptReAuth +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileContextProvider +import software.aws.toolkits.jetbrains.utils.isInjectedText +import software.aws.toolkits.jetbrains.utils.isQExpired +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererTriggerType +import java.util.concurrent.TimeUnit - artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper)) +@Service +class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { + private val codeInsightSettingsFacade = CodeInsightsSettingsFacade() + private var refreshFailure: Int = 0 + private val ongoingRequests = mutableMapOf() + val ongoingRequestsContext = mutableMapOf() + private var jobId = 0 + private var sessionContext: SessionContextNew? = null + + init { + Disposer.register(this, codeInsightSettingsFacade) } - fun testFetchArtifactFetcherReturnsBundledIfManifestIsNull() = runTest { - every { manifestFetcher.fetch() }.returns(null) + private var job: Job? = null + fun showRecommendationsInPopup( + editor: Editor, + triggerTypeInfo: TriggerTypeInfo, + latencyContext: LatencyContext, + ): Job? { + if (job == null || job?.isCompleted == true) { + job = cs.launch(getCoroutineBgContext()) { + doShowRecommendationsInPopup(editor, triggerTypeInfo, latencyContext) + } + } - assertThat(artifactManager.fetchArtifact(project)) - .isEqualTo( - PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") - ) + // did some wrangling, but compiler didn't believe this can't be null + return job + } + + private suspend fun doShowRecommendationsInPopup( + editor: Editor, + triggerTypeInfo: TriggerTypeInfo, + latencyContext: LatencyContext, + ) { + val project = editor.project ?: return + if (!isCodeWhispererEnabled(project)) return + + // try to refresh automatically if possible, otherwise ask user to login again + if (isQExpired(project)) { + // consider changing to only running once a ~minute since this is relatively expensive + // say the connection is un-refreshable if refresh fails for 3 times + val shouldReauth = if (refreshFailure < MAX_REFRESH_ATTEMPT) { + val attempt = withContext(getCoroutineBgContext()) { + promptReAuth(project) + } + + if (!attempt) { + refreshFailure++ + } + + attempt + } else { + true + } + + if (shouldReauth) { + return + } + } + + val psiFile = runReadAction { PsiDocumentManager.getInstance(project).getPsiFile(editor.document) } + + if (psiFile == null) { + LOG.debug { "No PSI file for the current document" } + if (triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + showCodeWhispererInfoHint(editor, message("codewhisperer.trigger.document.unsupported")) + } + return + } + val isInjectedFile = runReadAction { psiFile.isInjectedText() } + if (isInjectedFile) return + + val currentJobId = jobId++ + val requestContext = try { + getRequestContext(triggerTypeInfo, editor, project, psiFile) + } catch (e: Exception) { + LOG.debug { e.message.toString() } + return + } + val caretContext = requestContext.fileContextInfo.caretContext + ongoingRequestsContext.forEach { (k, v) -> + val vCaretContext = v.fileContextInfo.caretContext + if (vCaretContext == caretContext) { + LOG.debug { "same caretContext found from job: $k, left context ${vCaretContext.leftContextOnCurrentLine}, jobId: $currentJobId" } + return + } + } + + LOG.debug { + "Calling CodeWhisperer service, jobId: $currentJobId, trigger type: ${triggerTypeInfo.triggerType}" + + if (triggerTypeInfo.triggerType == CodewhispererTriggerType.AutoTrigger) { + ", auto-trigger type: ${triggerTypeInfo.automatedTriggerType}" + } else { + "" + } + } + + CodeWhispererInvocationStatusNew.getInstance().startInvocation() + + invokeCodeWhispererInBackground(requestContext, currentJobId, latencyContext) + } + + internal suspend fun invokeCodeWhispererInBackground(requestContext: RequestContextNew, currentJobId: Int, latencyContext: LatencyContext) { + ongoingRequestsContext[currentJobId] = requestContext + val sessionContext = sessionContext ?: SessionContextNew(requestContext.project, requestContext.editor, latencyContext = latencyContext) + + // In rare cases when there's an ongoing session and subsequent triggers are from a different project or editor -- + // we will cancel the existing session(since we've already moved to a different project or editor simply return. + if (requestContext.project != sessionContext.project || requestContext.editor != sessionContext.editor) { + disposeDisplaySession(false) + return + } + this.sessionContext = sessionContext + + val workerContexts = mutableListOf() + + // When session is disposed we will cancel this coroutine. The only places session can get disposed should be + // from CodeWhispererService.disposeDisplaySession(). + // It's possible and ok that coroutine will keep running until the next time we check it's state. + // As long as we don't show to the user extra info we are good. + var lastRecommendationIndex = -1 + + try { + var startTime = System.nanoTime() + CodeWhispererInvocationStatusNew.getInstance().setInvocationStart() + var requestCount = 0 + var nextToken: Either? = null + do { + val result = AmazonQLspService.executeAsyncIfRunning(requestContext.project) { server -> + val params = createInlineCompletionParams(requestContext.editor, requestContext.triggerTypeInfo, nextToken) + server.inlineCompletionWithReferences(params) + } + result?.thenAccept { completion -> + nextToken = completion.partialResultToken + requestCount++ + val endTime = System.nanoTime() + val latency = TimeUnit.NANOSECONDS.toMillis(endTime - startTime).toDouble() + startTime = endTime + val responseContext = ResponseContext(completion.sessionId) + logServiceInvocation(requestContext, responseContext, completion, latency, null) + lastRecommendationIndex += completion.items.size + + runInEdt { + // If delay is not met, add them to the worker queue and process them later. + // On first response, workers queue must be empty. If there's enough delay before showing, + // process CodeWhisperer UI rendering and workers queue will remain empty throughout this + // CodeWhisperer session. If there's not enough delay before showing, the CodeWhisperer UI rendering task + // will be added to the workers queue. + // On subsequent responses, if they see workers queue is not empty, it means the first worker + // task hasn't been finished yet, in this case simply add another task to the queue. If they + // see worker queue is empty, the previous tasks must have been finished before this. In this + // case render CodeWhisperer UI directly. + val workerContext = WorkerContextNew(requestContext, responseContext, completion) + if (workerContexts.isNotEmpty()) { + workerContexts.add(workerContext) + } else { + if (ongoingRequests.values.filterNotNull().isEmpty() && + !CodeWhispererInvocationStatusNew.getInstance().hasEnoughDelayToShowCodeWhisperer() + ) { + // It's the first response, and no enough delay before showing + projectCoroutineScope(requestContext.project).launch { + while (!CodeWhispererInvocationStatusNew.getInstance().hasEnoughDelayToShowCodeWhisperer()) { + delay(CodeWhispererConstants.POPUP_DELAY_CHECK_INTERVAL) + } + runInEdt { + workerContexts.forEach { + processCodeWhispererUI( + sessionContext, + it, + ongoingRequests[currentJobId], + cs, + currentJobId + ) + if (!ongoingRequests.contains(currentJobId)) { + job?.cancel() + } + } + workerContexts.clear() + } + } + workerContexts.add(workerContext) + } else { + // Have enough delay before showing for the first response, or it's subsequent responses + processCodeWhispererUI( + sessionContext, + workerContext, + ongoingRequests[currentJobId], + cs, + currentJobId + ) + if (!ongoingRequests.contains(currentJobId)) { + job?.cancel() + } + } + } + } + if (!cs.isActive) { + // If job is cancelled before we do another request, don't bother making + // another API call to save resources + LOG.debug { "Skipping sending remaining requests on inactive CodeWhisperer session exit" } + return@thenAccept + } + if (requestCount >= PAGINATION_REQUEST_COUNT_ALLOWED) { + LOG.debug { "Only $PAGINATION_REQUEST_COUNT_ALLOWED request per pagination session for now" } + CodeWhispererInvocationStatusNew.getInstance().finishInvocation() + return@thenAccept + } + } + } while (nextToken != null) + } catch (e: Exception) { + val requestId: String + val sessionId: String + val displayMessage: String + + if (e is CodeWhispererRuntimeException) { + requestId = e.requestId().orEmpty() + sessionId = e.awsErrorDetails().sdkHttpResponse().headers().getOrDefault(KET_SESSION_ID, listOf(requestId))[0] + displayMessage = e.awsErrorDetails().errorMessage() ?: message("codewhisperer.trigger.error.server_side") + } else { + sessionId = "" + val statusCode = if (e is SdkServiceException) e.statusCode() else 0 + displayMessage = + if (statusCode >= 500) { + message("codewhisperer.trigger.error.server_side") + } else { + message("codewhisperer.trigger.error.client_side") + } + if (statusCode < 500) { + LOG.debug(e) { "Error invoking CodeWhisperer service" } + } + } + val exceptionType = e::class.simpleName + val responseContext = ResponseContext(sessionId) + CodeWhispererInvocationStatusNew.getInstance().setInvocationSessionId(sessionId) + logServiceInvocation(requestContext, responseContext, null, null, exceptionType) + + if (e is ThrottlingException && + e.message == CodeWhispererConstants.THROTTLING_MESSAGE + ) { + CodeWhispererExplorerActionManager.getInstance().setSuspended(requestContext.project) + if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + notifyErrorCodeWhispererUsageLimit(requestContext.project) + } + } else { + if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + // We should only show error hint when CodeWhisperer popup is not visible, + // and make it silent if CodeWhisperer popup is showing. + runInEdt { + if (!CodeWhispererInvocationStatusNew.getInstance().isDisplaySessionActive()) { + showCodeWhispererErrorHint(requestContext.editor, displayMessage) + } + } + } + } + CodeWhispererInvocationStatusNew.getInstance().finishInvocation() + runInEdt { + CodeWhispererPopupManagerNew.getInstance().updatePopupPanel(sessionContext) + } + } + } + + @RequiresEdt + private fun processCodeWhispererUI( + sessionContext: SessionContextNew, + workerContext: WorkerContextNew, + currStates: InvocationContextNew?, + coroutine: CoroutineScope, + jobId: Int, + ) { + val requestContext = workerContext.requestContext + val responseContext = workerContext.responseContext + val completions = workerContext.completions + + // At this point when we are in EDT, the state of the popup will be thread-safe + // across this thread execution, so if popup is disposed, we will stop here. + // This extra check is needed because there's a time between when we get the response and + // when we enter the EDT. + if (!coroutine.isActive || sessionContext.isDisposed()) { + LOG.debug { "Stop showing CodeWhisperer recommendations on CodeWhisperer session exit. session id: ${completions.sessionId}, jobId: $jobId" } + return + } + + if (requestContext.editor.isDisposed) { + LOG.debug { "Stop showing all CodeWhisperer recommendations since editor is disposed. session id: ${completions.sessionId}, jobId: $jobId" } + disposeDisplaySession(false) + return + } + + CodeWhispererInvocationStatusNew.getInstance().finishInvocation() + + val caretMovement = CodeWhispererEditorManagerNew.getInstance().getCaretMovement( + requestContext.editor, + requestContext.caretPosition + ) + val isPopupShowing = checkRecommendationsValidity(currStates, false) + val nextStates: InvocationContextNew? + if (currStates == null) { + // first response for the jobId + nextStates = initStates(jobId, requestContext, responseContext, completions, caretMovement) + + // receiving a null state means caret has moved backward, + // so we are going to cancel the current job + if (nextStates == null) { + return + } + } else { + // subsequent responses for the jobId + nextStates = updateStates(currStates, completions) + } + LOG.debug { "Adding ${completions.items.size} completions to the session. session id: ${completions.sessionId}, jobId: $jobId" } + + // TODO: may have bug when it's a mix of auto-trigger + manual trigger + val hasAtLeastOneValid = checkRecommendationsValidity(nextStates, true) + val allSuggestions = ongoingRequests.values.filterNotNull().flatMap { it.recommendationContext.details } + val valid = allSuggestions.count { !it.isDiscarded } + LOG.debug { "Suggestions status: valid: $valid, discarded: ${allSuggestions.size - valid}" } + + // If there are no recommendations at all in this session, we need to manually send the user decision event here + // since it won't be sent automatically later + // TODO: may have bug; visit later + if (nextStates.recommendationContext.details.isEmpty()) { + LOG.debug { "Received just an empty list from this session. session id: ${completions.sessionId}" } + } + if (!hasAtLeastOneValid) { + LOG.debug { "None of the recommendations are valid, exiting current CodeWhisperer pagination session" } + // If there's only one ongoing request, after disposing this, the entire session will also end + if (ongoingRequests.keys.size == 1) { + disposeDisplaySession(false) + } else { + disposeJob(jobId) + sessionContext.selectedIndex = CodeWhispererPopupManagerNew.getInstance().findNewSelectedIndex(true, sessionContext.selectedIndex) + } + } else { + updateCodeWhisperer(sessionContext, nextStates, isPopupShowing) + } } - fun testFetchArtifactDoesNotHaveAnyValidLspVersionsReturnsBundled() = runTest { - every { manifestFetcher.fetch() }.returns(Manifest()) + private fun initStates( + jobId: Int, + requestContext: RequestContextNew, + responseContext: ResponseContext, + completions: InlineCompletionListWithReferences, + caretMovement: CaretMovement, + ): InvocationContextNew? { + val visualPosition = requestContext.editor.caretModel.visualPosition + + if (caretMovement == CaretMovement.MOVE_BACKWARD) { + LOG.debug { "Caret moved backward, discarding all of the recommendations. Session Id: ${completions.sessionId}, jobId: $jobId" } + val detailContexts = completions.items.map { + DetailContext("", it, true, getCompletionType(it)) + }.toMutableList() + val recommendationContext = RecommendationContextNew(detailContexts, "", VisualPosition(0, 0), jobId) + ongoingRequests[jobId] = buildInvocationContext(requestContext, responseContext, recommendationContext) + disposeDisplaySession(false) + return null + } - every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( - ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList()) + val userInput = + if (caretMovement == CaretMovement.NO_CHANGE) { + LOG.debug { "Caret position not changed since invocation. Session Id: ${completions.sessionId}" } + "" + } else { + LOG.debug { "Caret position moved forward since invocation. Session Id: ${completions.sessionId}" } + CodeWhispererEditorManagerNew.getInstance().getUserInputSinceInvocation( + requestContext.editor, + requestContext.caretPosition.offset + ) + } + val detailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( + userInput, + completions, ) + val recommendationContext = RecommendationContextNew(detailContexts, userInput, visualPosition, jobId) + ongoingRequests[jobId] = buildInvocationContext(requestContext, responseContext, recommendationContext) + return ongoingRequests[jobId] + } - assertThat(artifactManager.fetchArtifact(project)) - .isEqualTo( - PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") + private fun updateStates( + states: InvocationContextNew, + completions: InlineCompletionListWithReferences, + ): InvocationContextNew { + val recommendationContext = states.recommendationContext + val newDetailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( + recommendationContext.userInput, + completions, + ) + + recommendationContext.details.addAll(newDetailContexts) + return states + } + + private fun checkRecommendationsValidity(states: InvocationContextNew?, showHint: Boolean): Boolean { + if (states == null) return false + val details = states.recommendationContext.details + + // set to true when at least one is not discarded or empty + val hasAtLeastOneValid = details.any { !it.isDiscarded && it.completion.insertText.isNotEmpty() } + + if (!hasAtLeastOneValid && showHint && states.requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + showCodeWhispererInfoHint( + states.requestContext.editor, + message("codewhisperer.popup.no_recommendations") ) + } + return hasAtLeastOneValid + } + + private fun updateCodeWhisperer(sessionContext: SessionContextNew, states: InvocationContextNew, recommendationAdded: Boolean) { + CodeWhispererPopupManagerNew.getInstance().changeStatesForShowing(sessionContext, states, recommendationAdded) } - fun testGetLSPVersionsFromManifestWithSpecifiedRangeExcludesEndMajorVersion() = runTest { - val newManifest = Manifest(versions = listOf(Version(serverVersion = "2.0.0"))) - val result = artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(newManifest) - assertThat(result.inRangeVersions).isEmpty() + @RequiresEdt + private fun disposeJob(jobId: Int) { + ongoingRequests[jobId]?.let { Disposer.dispose(it) } + ongoingRequests.remove(jobId) + ongoingRequestsContext.remove(jobId) } - fun testFetchArtifactIfInRangeVersionsAreNotAvailableShouldFallbackToLocalLsp() = runTest { - val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + @RequiresEdt + fun disposeDisplaySession(accept: Boolean) { + // avoid duplicate session disposal logic + if (sessionContext == null || sessionContext?.isDisposed() == true) return - every { manifestFetcher.fetch() }.returns(Manifest()) - every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + sessionContext?.let { + it.hasAccepted = accept + Disposer.dispose(it) + } + sessionContext = null + val jobIds = ongoingRequests.keys.toList() + jobIds.forEach { jobId -> disposeJob(jobId) } + ongoingRequests.clear() + ongoingRequestsContext.clear() + } + + fun getAllSuggestionsPreviewInfo() = + ongoingRequests.values.filterNotNull().flatMap { element -> + val context = element.recommendationContext + context.details.map { + PreviewContext(context.jobId, it, context.userInput, context.typeahead) + } + } + + fun getAllPaginationSessions() = ongoingRequests + + fun getRequestContext( + triggerTypeInfo: TriggerTypeInfo, + editor: Editor, + project: Project, + psiFile: PsiFile, + ): RequestContextNew { + // 1. file context + val fileContext: FileContextInfo = runReadAction { FileContextProvider.getInstance(project).extractFileContext(editor, psiFile) } - artifactManager.fetchArtifact(project) + // 3. caret position + val caretPosition = runReadAction { getCaretPosition(editor) } - verify(exactly = 1) { manifestFetcher.fetch() } - verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) } + // 4. connection + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + + return RequestContextNew(project, editor, triggerTypeInfo, caretPosition, fileContext, connection) } - fun testFetchArtifactHaveValidVersionInLocalSystem() = runTest { - val target = VersionTarget(platform = "temp", arch = "temp") - val versions = listOf(Version("1.0.0", targets = listOf(target))) + private fun buildInvocationContext( + requestContext: RequestContextNew, + responseContext: ResponseContext, + recommendationContext: RecommendationContextNew, + ): InvocationContextNew { + // Creating a disposable for managing all listeners lifecycle attached to the popup. + // previously(before pagination) we use popup as the parent disposable. + // After pagination, listeners need to be updated as states are updated, for the same popup, + // so disposable chain becomes popup -> disposable -> listeners updates, and disposable gets replaced on every + // state update. + val states = InvocationContextNew(requestContext, responseContext, recommendationContext) + Disposer.register(states) { + job?.cancel(CancellationException("Cancelling the current coroutine when the pagination session context is disposed")) + } + return states + } - every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( - ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) - ) - every { manifestFetcher.fetch() }.returns(Manifest()) + private fun createInlineCompletionParams( + editor: Editor, + triggerTypeInfo: TriggerTypeInfo, + nextToken: Either?, + ): InlineCompletionWithReferencesParams = + ReadAction.compute { + InlineCompletionWithReferencesParams( + context = InlineCompletionContext( + // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind + triggerKind = when (triggerTypeInfo.triggerType) { + CodewhispererTriggerType.OnDemand -> InlineCompletionTriggerKind.Invoke + CodewhispererTriggerType.AutoTrigger -> InlineCompletionTriggerKind.Automatic + else -> InlineCompletionTriggerKind.Invoke + } + ), + documentChangeParams = null, + openTabFilepaths = null, + ).apply { + textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile ?: return@compute null)) + position = Position( + editor.caretModel.primaryCaret.logicalPosition.line, + editor.caretModel.primaryCaret.logicalPosition.column + ) + if (nextToken != null) { + workDoneToken = nextToken + } + } + } - mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") - every { getCurrentOS() }.returns("temp") - every { getCurrentArchitecture() }.returns("temp") + private fun logServiceInvocation( + requestContext: RequestContextNew, + responseContext: ResponseContext, + completions: InlineCompletionListWithReferences?, + latency: Double?, + exceptionType: String?, + ) { + val recommendationLogs = completions?.items?.map { it.insertText.trimEnd() } + ?.reduceIndexedOrNull { index, acc, recommendation -> "$acc\n[${index + 1}]\n$recommendation" } + LOG.info { + "SessionId: ${responseContext.sessionId}, " + + "Jetbrains IDE: ${ApplicationInfo.getInstance().fullApplicationName}, " + + "IDE version: ${ApplicationInfo.getInstance().apiVersion}, " + + "Filename: ${requestContext.fileContextInfo.filename}, " + + "Left context of current line: ${requestContext.fileContextInfo.caretContext.leftContextOnCurrentLine}, " + + "Cursor line: ${requestContext.caretPosition.line}, " + + "Caret offset: ${requestContext.caretPosition.offset}, " + + latency?.let { "Latency: $latency, " }.orEmpty() + + exceptionType?.let { "Exception Type: $it, " }.orEmpty() + + "Recommendations: \n${recommendationLogs ?: "None"}" + } + } - every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(false) - coEvery { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } returns tempDir - every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + fun canDoInvocation(editor: Editor, type: CodewhispererTriggerType): Boolean { + editor.project?.let { + if (!isCodeWhispererEnabled(it)) { + return false + } + } - artifactManager.fetchArtifact(project) + if (type == CodewhispererTriggerType.AutoTrigger && !CodeWhispererExplorerActionManager.getInstance().isAutoEnabled()) { + LOG.debug { "CodeWhisperer auto-trigger is disabled, not invoking service" } + return false + } - coVerify(exactly = 1) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } - verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + if (CodeWhispererInvocationStatusNew.getInstance().isDisplaySessionActive()) { + LOG.debug { "Find an existing CodeWhisperer session before triggering CodeWhisperer, not invoking service" } + return false + } + return true } - fun testFetchArtifactDoesNotHaveValidVersionInLocalSystem() = runTest { - val target = VersionTarget(platform = "temp", arch = "temp") - val versions = listOf(Version("1.0.0", targets = listOf(target))) - val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + fun showCodeWhispererInfoHint(editor: Editor, message: String) { + HintManager.getInstance().showInformationHint(editor, message, HintManager.UNDER) + } - every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( - ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) - ) - every { manifestFetcher.fetch() }.returns(Manifest()) + fun showCodeWhispererErrorHint(editor: Editor, message: String) { + HintManager.getInstance().showErrorHint(editor, message, HintManager.UNDER) + } - mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") - every { getCurrentOS() }.returns("temp") - every { getCurrentArchitecture() }.returns("temp") + override fun dispose() {} - every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(true) - every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs - every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + companion object { + private val LOG = getLogger() + private const val MAX_REFRESH_ATTEMPT = 3 + private const val PAGINATION_REQUEST_COUNT_ALLOWED = 1 - artifactManager.fetchArtifact(project) + val CODEWHISPERER_INTELLISENSE_POPUP_ON_HOVER: Topic = Topic.create( + "CodeWhisperer intelliSense popup on hover", + CodeWhispererIntelliSenseOnHoverListener::class.java + ) + val KEY_SESSION_CONTEXT = Key.create("codewhisperer.session") - coVerify(exactly = 0) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } - verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + fun getInstance(): CodeWhispererServiceNew = service() + const val KET_SESSION_ID = "x-amzn-SessionId" } } + +data class RequestContextNew( + val project: Project, + val editor: Editor, + val triggerTypeInfo: TriggerTypeInfo, + val caretPosition: CaretPosition, + val fileContextInfo: FileContextInfo, + val connection: ToolkitConnection?, +) + +interface CodeWhispererIntelliSenseOnHoverListener { + fun onEnter() {} +} From 2f9577940b1dd61508874b1d00b0d2c8694f1ff7 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 02:28:19 -0800 Subject: [PATCH 19/35] Un-segregate VirtualFile nullability fixes These files only have null-safety changes that work in all versions: - CodeWhispererService.kt, CodeWhispererServiceNew.kt - AmazonQLanguageClientImpl.kt, TextDocumentServiceHandler.kt Keep CwmProblemsViewMutator.kt segregated (BackendToolWindowHost API removed) --- .../service/CodeWhispererService.kt | 655 ----------------- .../service/CodeWhispererServiceNew.kt | 665 ------------------ .../service/CodeWhispererService.kt | 0 .../service/CodeWhispererServiceNew.kt | 0 .../amazonq/lsp/AmazonQLanguageClientImpl.kt | 605 ---------------- .../TextDocumentServiceHandler.kt | 259 ------- .../amazonq/lsp/AmazonQLanguageClientImpl.kt | 0 .../TextDocumentServiceHandler.kt | 0 8 files changed, 2184 deletions(-) delete mode 100644 plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt delete mode 100644 plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt rename plugins/amazonq/codewhisperer/jetbrains-community/{src-253+ => src}/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt (100%) rename plugins/amazonq/codewhisperer/jetbrains-community/{src-253+ => src}/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt (100%) delete mode 100644 plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt delete mode 100644 plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt rename plugins/amazonq/shared/jetbrains-community/{src-253+ => src}/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt (100%) rename plugins/amazonq/shared/jetbrains-community/{src-253+ => src}/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt (100%) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt deleted file mode 100644 index d4d264e4d13..00000000000 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt +++ /dev/null @@ -1,655 +0,0 @@ -// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.codewhisperer.service - -import com.intellij.codeInsight.hint.HintManager -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationInfo -import com.intellij.openapi.application.ReadAction -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.application.runReadAction -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.VisualPosition -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.popup.JBPopup -import com.intellij.openapi.util.Disposer -import com.intellij.psi.PsiDocumentManager -import com.intellij.psi.PsiFile -import com.intellij.util.concurrency.annotations.RequiresEdt -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.future.await -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.eclipse.lsp4j.DidChangeTextDocumentParams -import org.eclipse.lsp4j.Position -import org.eclipse.lsp4j.TextDocumentContentChangeEvent -import org.eclipse.lsp4j.TextDocumentIdentifier -import org.eclipse.lsp4j.VersionedTextDocumentIdentifier -import org.eclipse.lsp4j.jsonrpc.JsonRpcException -import org.eclipse.lsp4j.jsonrpc.messages.Either -import software.amazon.awssdk.core.exception.SdkServiceException -import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException -import software.aws.toolkits.core.utils.debug -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.coroutines.EDT -import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection -import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.GetConfigurationFromServerParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionContext -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionListWithReferences -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionTriggerKind -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionWithReferencesParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString -import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator -import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManager -import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.getCaretPosition -import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager -import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled -import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition -import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext -import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo -import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext -import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext -import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationContext -import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo -import software.aws.toolkits.jetbrains.services.codewhisperer.model.WorkerContext -import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager -import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CaretMovement -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeInsightsSettingsFacade -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getCompletionType -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyErrorCodeWhispererUsageLimit -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.promptReAuth -import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileContextProvider -import software.aws.toolkits.jetbrains.utils.isInjectedText -import software.aws.toolkits.jetbrains.utils.isQExpired -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.CodewhispererTriggerType -import java.net.URI -import java.nio.file.Paths -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit - -@Service -class CodeWhispererService(private val cs: CoroutineScope) : Disposable { - private val codeInsightSettingsFacade = CodeInsightsSettingsFacade() - private var refreshFailure: Int = 0 - - init { - Disposer.register(this, codeInsightSettingsFacade) - } - - private var job: Job? = null - fun showRecommendationsInPopup( - editor: Editor, - triggerTypeInfo: TriggerTypeInfo, - latencyContext: LatencyContext, - ): Job? { - if (job == null || job?.isCompleted == true) { - job = cs.launch(getCoroutineBgContext()) { - doShowRecommendationsInPopup(editor, triggerTypeInfo, latencyContext) - } - } - - // did some wrangling, but compiler didn't believe this can't be null - return job - } - - private suspend fun doShowRecommendationsInPopup( - editor: Editor, - triggerTypeInfo: TriggerTypeInfo, - latencyContext: LatencyContext, - ) { - val project = editor.project ?: return - if (!isCodeWhispererEnabled(project)) return - - // try to refresh automatically if possible, otherwise ask user to login again - if (isQExpired(project)) { - // consider changing to only running once a ~minute since this is relatively expensive - // say the connection is un-refreshable if refresh fails for 3 times - val shouldReauth = if (refreshFailure < MAX_REFRESH_ATTEMPT) { - val attempt = withContext(getCoroutineBgContext()) { - promptReAuth(project) - } - - if (!attempt) { - refreshFailure++ - } - - attempt - } else { - true - } - - if (shouldReauth) { - return - } - } - - val psiFile = runReadAction { PsiDocumentManager.getInstance(project).getPsiFile(editor.document) } - - if (psiFile == null) { - LOG.debug { "No PSI file for the current document" } - if (triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - showCodeWhispererInfoHint(editor, message("codewhisperer.trigger.document.unsupported")) - } - return - } - val isInjectedFile = runReadAction { psiFile.isInjectedText() } - if (isInjectedFile) return - - val requestContext = try { - getRequestContext(triggerTypeInfo, editor, project, psiFile, latencyContext) - } catch (e: Exception) { - LOG.debug { e.message.toString() } - return - } - - // TODO flare: since IDE local language check got removed, flare needs to implement json aws template support only - - LOG.debug { - "Calling CodeWhisperer service, trigger type: ${triggerTypeInfo.triggerType}" + - if (triggerTypeInfo.triggerType == CodewhispererTriggerType.AutoTrigger) { - ", auto-trigger type: ${triggerTypeInfo.automatedTriggerType}" - } else { - "" - } - } - - val invocationStatus = CodeWhispererInvocationStatus.getInstance() - if (invocationStatus.checkExistingInvocationAndSet()) { - return - } - - invokeCodeWhispererInBackground(requestContext) - } - - internal suspend fun invokeCodeWhispererInBackground(requestContext: RequestContext): Job { - val popup = withContext(EDT) { - CodeWhispererPopupManager.getInstance().initPopup().also { - Disposer.register(it) { CodeWhispererInvocationStatus.getInstance().finishInvocation() } - } - } - - var states: InvocationContext? = null - - val job = cs.launch { - try { - var startTime = System.nanoTime() - CodeWhispererInvocationStatus.getInstance().setInvocationStart() - var nextToken: Either? = null - do { - val result = AmazonQLspService.executeAsyncIfRunning(requestContext.project) { server -> - val params = createInlineCompletionParams(requestContext.editor, requestContext.triggerTypeInfo, nextToken) - server.inlineCompletionWithReferences(params) - } - val completion = result?.await() - if (completion == null) { - // no result / not running - CodeWhispererInvocationStatus.getInstance().finishInvocation() - break - } - - nextToken = completion.partialResultToken - val endTime = System.nanoTime() - val latency = TimeUnit.NANOSECONDS.toMillis(endTime - startTime).toDouble() - startTime = endTime - val responseContext = ResponseContext(completion.sessionId) - logServiceInvocation(requestContext, responseContext, completion, latency, null) - - val workerContext = WorkerContext(requestContext, responseContext, completion, popup) - runInEdt { - states = processCodeWhispererUI(workerContext, states) - } - if (popup.isDisposed) { - LOG.debug { "Skipping sending remaining requests on CodeWhisperer session exit" } - return@launch - } - } while (nextToken != null && nextToken.left.isNotEmpty()) - } catch (e: Exception) { - // TODO flare: flare doesn't return exceptions - val sessionId = "" - val displayMessage: String - - if (e is JsonRpcException) { - // TODO: only log once to avoid auto-trigger spam? - LOG.debug(e) { - "Error talking to Q LSP server" - } - displayMessage = "Q LSP server failed to communicate, try restarting the current project." - } else { - val statusCode = if (e is SdkServiceException) e.statusCode() else 0 - displayMessage = - if (statusCode >= 500) { - message("codewhisperer.trigger.error.server_side") - } else { - message("codewhisperer.trigger.error.client_side") - } - if (statusCode < 500) { - LOG.debug(e) { "Error invoking CodeWhisperer service" } - } - } - val exceptionType = e::class.simpleName - val responseContext = ResponseContext(sessionId) - CodeWhispererInvocationStatus.getInstance().setInvocationSessionId(sessionId) - logServiceInvocation(requestContext, responseContext, null, null, exceptionType) - - if (e is ThrottlingException && - e.message == CodeWhispererConstants.THROTTLING_MESSAGE - ) { - CodeWhispererExplorerActionManager.getInstance().setSuspended(requestContext.project) - if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - notifyErrorCodeWhispererUsageLimit(requestContext.project) - } - } else { - if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - // We should only show error hint when CodeWhisperer popup is not visible, - // and make it silent if CodeWhisperer popup is showing. - if (!CodeWhispererInvocationStatus.getInstance().isDisplaySessionActive()) { - showCodeWhispererErrorHint(requestContext.editor, displayMessage) - } - } - } - CodeWhispererInvocationStatus.getInstance().finishInvocation() - runInEdt { - states?.let { - CodeWhispererPopupManager.getInstance().updatePopupPanel( - it, - CodeWhispererPopupManager.getInstance().sessionContext - ) - } - } - } finally { - CodeWhispererInvocationStatus.getInstance().setInvocationComplete() - } - } - - return job - } - - @RequiresEdt - private fun processCodeWhispererUI(workerContext: WorkerContext, currStates: InvocationContext?): InvocationContext? { - val requestContext = workerContext.requestContext - val responseContext = workerContext.responseContext - val completions = workerContext.completions - val popup = workerContext.popup - - // At this point when we are in EDT, the state of the popup will be thread-safe - // across this thread execution, so if popup is disposed, we will stop here. - // This extra check is needed because there's a time between when we get the response and - // when we enter the EDT. - if (popup.isDisposed) { - LOG.debug { "Stop showing CodeWhisperer recommendations on CodeWhisperer session exit. session id: ${responseContext.sessionId}" } - return null - } - - if (requestContext.editor.isDisposed) { - LOG.debug { "Stop showing CodeWhisperer recommendations since editor is disposed. session id: ${responseContext.sessionId}" } - sendDiscardedUserDecisionEventForAll(requestContext.project, requestContext.latencyContext, responseContext.sessionId, completions) - CodeWhispererPopupManager.getInstance().cancelPopup(popup) - return null - } - - if (completions.partialResultToken?.left.isNullOrEmpty()) { - CodeWhispererInvocationStatus.getInstance().finishInvocation() - } - - val caretMovement = CodeWhispererEditorManager.getInstance().getCaretMovement( - requestContext.editor, - requestContext.caretPosition - ) - val isPopupShowing: Boolean - val nextStates: InvocationContext? - if (currStates == null) { - // first response - nextStates = initStates(requestContext, responseContext, completions, caretMovement, popup) - isPopupShowing = false - - // receiving a null state means caret has moved backward or there's a conflict with - // Intellisense popup, so we are going to cancel the job - if (nextStates == null) { - LOG.debug { "Cancelling popup and exiting CodeWhisperer session. session id: ${responseContext.sessionId}" } - sendDiscardedUserDecisionEventForAll(requestContext.project, requestContext.latencyContext, responseContext.sessionId, completions) - CodeWhispererPopupManager.getInstance().cancelPopup(popup) - return null - } - } else { - // subsequent responses - nextStates = updateStates(currStates, completions) - isPopupShowing = checkRecommendationsValidity(currStates, false) - } - - val hasAtLeastOneValid = checkRecommendationsValidity(nextStates, completions.partialResultToken == null) - - // If there are no recommendations at all in this session, we need to manually send the user decision event here - // since it won't be sent automatically later - if (!hasAtLeastOneValid) { - if (completions.partialResultToken?.left.isNullOrEmpty()) { - LOG.debug { "None of the recommendations are valid, exiting CodeWhisperer session" } - CodeWhispererPopupManager.getInstance().cancelPopup(popup) - return null - } - } else { - updateCodeWhisperer(nextStates, isPopupShowing) - } - return nextStates - } - - private fun initStates( - requestContext: RequestContext, - responseContext: ResponseContext, - completions: InlineCompletionListWithReferences, - caretMovement: CaretMovement, - popup: JBPopup, - ): InvocationContext? { - val visualPosition = requestContext.editor.caretModel.visualPosition - - if (CodeWhispererPopupManager.getInstance().hasConflictingPopups(requestContext.editor)) { - LOG.debug { "Detect conflicting popup window with CodeWhisperer popup, not showing CodeWhisperer popup" } - return null - } - - if (caretMovement == CaretMovement.MOVE_BACKWARD) { - LOG.debug { "Caret moved backward, discarding all of the recommendations. Session Id: ${completions.sessionId}" } - return null - } - val userInput = - if (caretMovement == CaretMovement.NO_CHANGE) { - LOG.debug { "Caret position not changed since invocation. Session Id: ${completions.sessionId}" } - "" - } else { - LOG.debug { "Caret position moved forward since invocation. Session Id: ${completions.sessionId}" } - CodeWhispererEditorManager.getInstance().getUserInputSinceInvocation( - requestContext.editor, - requestContext.caretPosition.offset - ) - } - val detailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( - userInput, - completions, - ) - val recommendationContext = RecommendationContext(detailContexts, userInput, visualPosition) - return buildInvocationContext(requestContext, responseContext, recommendationContext, popup) - } - - private fun updateStates( - states: InvocationContext, - completions: InlineCompletionListWithReferences, - ): InvocationContext { - val recommendationContext = states.recommendationContext - val details = recommendationContext.details - val newDetailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( - recommendationContext.userInput, - completions, - ) - Disposer.dispose(states) - - val updatedStates = states.copy( - recommendationContext = recommendationContext.copy(details = details + newDetailContexts) - ) - Disposer.register(states.popup, updatedStates) - CodeWhispererPopupManager.getInstance().initPopupListener(updatedStates) - return updatedStates - } - - private fun checkRecommendationsValidity(states: InvocationContext, showHint: Boolean): Boolean { - val details = states.recommendationContext.details - - // set to true when at least one is not discarded or empty - val hasAtLeastOneValid = details.any { !it.isDiscarded && it.completion.insertText.isNotEmpty() } - - if (!hasAtLeastOneValid && showHint && states.requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - showCodeWhispererInfoHint( - states.requestContext.editor, - message("codewhisperer.popup.no_recommendations") - ) - } - return hasAtLeastOneValid - } - - private fun updateCodeWhisperer(states: InvocationContext, recommendationAdded: Boolean) { - CodeWhispererPopupManager.getInstance().changeStates(states, 0, recommendationAdded) - } - - private fun sendDiscardedUserDecisionEventForAll( - project: Project, - latencyContext: LatencyContext, - sessionId: String, - completions: InlineCompletionListWithReferences, - ) { - val detailContexts = completions.items.map { - DetailContext(it.itemId, it, true, getCompletionType(it)) - } - val recommendationContext = RecommendationContext(detailContexts, "", VisualPosition(0, 0)) - CodeWhispererTelemetryService.getInstance().sendUserTriggerDecisionEvent(project, latencyContext, sessionId, recommendationContext) - } - - suspend fun getRequestContext( - triggerTypeInfo: TriggerTypeInfo, - editor: Editor, - project: Project, - psiFile: PsiFile, - latencyContext: LatencyContext, - ): RequestContext { - // 1. file context - val fileContext: FileContextInfo = runReadAction { FileContextProvider.getInstance(project).extractFileContext(editor, psiFile) } - - // 3. caret position - val caretPosition = runReadAction { getCaretPosition(editor) } - - // 4. connection - val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) - - // 5. customization - val customizationArn = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn - - var workspaceId: String? = null - try { - val workspacesInfos = getWorkspaceIds(project).get().workspaces - for (workspaceInfo in workspacesInfos) { - val workspaceRootPath = Paths.get(URI(workspaceInfo.workspaceRoot)).toString() - if (psiFile.virtualFile.path.startsWith(workspaceRootPath)) { - workspaceId = workspaceInfo.workspaceId - LOG.info { "Found workspaceId from LSP '$workspaceId'" } - break - } - } - } catch (e: Exception) { - LOG.warn { "Cannot get workspaceId from LSP'$e'" } - } - return RequestContext( - project, - editor, - triggerTypeInfo, - caretPosition, - fileContext, - connection, - latencyContext, - customizationArn, - workspaceId, - ) - } - - suspend fun getWorkspaceIds(project: Project): CompletableFuture { - val payload = GetConfigurationFromServerParams( - section = "aws.q.workspaceContext" - ) - return AmazonQLspService.executeAsyncIfRunning(project) { server -> - server.getConfigurationFromServer(payload) - } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) - } - - private fun buildInvocationContext( - requestContext: RequestContext, - responseContext: ResponseContext, - recommendationContext: RecommendationContext, - popup: JBPopup, - ): InvocationContext { - addPopupChildDisposables(popup) - // Creating a disposable for managing all listeners lifecycle attached to the popup. - // previously(before pagination) we use popup as the parent disposable. - // After pagination, listeners need to be updated as states are updated, for the same popup, - // so disposable chain becomes popup -> disposable -> listeners updates, and disposable gets replaced on every - // state update. - val states = InvocationContext(requestContext, responseContext, recommendationContext, popup) - Disposer.register(popup, states) - CodeWhispererPopupManager.getInstance().initPopupListener(states) - return states - } - - fun createInlineCompletionParams( - editor: Editor, - triggerTypeInfo: TriggerTypeInfo, - nextToken: Either?, - ): InlineCompletionWithReferencesParams = - ReadAction.compute { - InlineCompletionWithReferencesParams( - context = InlineCompletionContext( - // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind - triggerKind = when (triggerTypeInfo.triggerType) { - CodewhispererTriggerType.OnDemand -> InlineCompletionTriggerKind.Invoke - CodewhispererTriggerType.AutoTrigger -> InlineCompletionTriggerKind.Automatic - else -> InlineCompletionTriggerKind.Invoke - } - ), - documentChangeParams = - if (triggerTypeInfo.automatedTriggerType == CodeWhispererAutomatedTriggerType.IntelliSense()) { - DidChangeTextDocumentParams( - VersionedTextDocumentIdentifier(), - listOf( - TextDocumentContentChangeEvent( - null, - CodeWhispererAutomatedTriggerType.IntelliSense().toString() - ) - ), - ) - } else { - null - }, - openTabFilepaths = editor.project?.let { project -> - com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) - .openFiles.mapNotNull { toUriString(it) } - }.orEmpty(), - ).apply { - textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile)) - position = Position( - editor.caretModel.primaryCaret.logicalPosition.line, - editor.caretModel.primaryCaret.logicalPosition.column - ) - if (nextToken != null) { - partialResultToken = nextToken - } - } - } - - private fun addPopupChildDisposables(popup: JBPopup) { - codeInsightSettingsFacade.disableCodeInsightUntil(popup) - - Disposer.register(popup) { - CodeWhispererPopupManager.getInstance().reset() - } - } - - private fun logServiceInvocation( - requestContext: RequestContext, - responseContext: ResponseContext, - completion: InlineCompletionListWithReferences?, - latency: Double?, - exceptionType: String?, - ) { - val recommendationLogs = completion?.items?.map { it.insertText.trimEnd() } - ?.reduceIndexedOrNull { index, acc, recommendation -> "$acc\n[${index + 1}]\n$recommendation" } - LOG.info { - "SessionId: ${responseContext.sessionId}, " + - "Jetbrains IDE: ${ApplicationInfo.getInstance().fullApplicationName}, " + - "IDE version: ${ApplicationInfo.getInstance().apiVersion}, " + - "Filename: ${requestContext.fileContextInfo.filename}, " + - "Left context of current line: ${requestContext.fileContextInfo.caretContext.leftContextOnCurrentLine}, " + - "Cursor line: ${requestContext.caretPosition.line}, " + - "Caret offset: ${requestContext.caretPosition.offset}, " + - (latency?.let { "Latency: $latency, " } ?: "") + - (exceptionType?.let { "Exception Type: $it, " } ?: "") + - "Recommendations: \n${recommendationLogs ?: "None"}" - } - } - - fun canDoInvocation(editor: Editor, type: CodewhispererTriggerType): Boolean { - editor.project?.let { - if (!isCodeWhispererEnabled(it)) { - return false - } - } - - if (type == CodewhispererTriggerType.AutoTrigger && !CodeWhispererExplorerActionManager.getInstance().isAutoEnabled()) { - LOG.debug { "CodeWhisperer auto-trigger is disabled, not invoking service" } - return false - } - - if (CodeWhispererPopupManager.getInstance().hasConflictingPopups(editor)) { - LOG.debug { "Find other active popup windows before triggering CodeWhisperer, not invoking service" } - return false - } - - if (CodeWhispererInvocationStatus.getInstance().isDisplaySessionActive()) { - LOG.debug { "Find an existing CodeWhisperer popup window before triggering CodeWhisperer, not invoking service" } - return false - } - return true - } - - private fun showCodeWhispererInfoHint(editor: Editor, message: String) { - runInEdt { - HintManager.getInstance().showInformationHint(editor, message, HintManager.UNDER) - } - } - - private fun showCodeWhispererErrorHint(editor: Editor, message: String) { - runInEdt { - HintManager.getInstance().showErrorHint(editor, message, HintManager.UNDER) - } - } - - override fun dispose() {} - - companion object { - private val LOG = getLogger() - private const val MAX_REFRESH_ATTEMPT = 3 - - fun getInstance(): CodeWhispererService = service() - const val KET_SESSION_ID = "x-amzn-SessionId" - private var reAuthPromptShown = false - - fun markReAuthPromptShown() { - reAuthPromptShown = true - } - - fun hasReAuthPromptBeenShown() = reAuthPromptShown - } -} - -data class RequestContext( - val project: Project, - val editor: Editor, - val triggerTypeInfo: TriggerTypeInfo, - val caretPosition: CaretPosition, - val fileContextInfo: FileContextInfo, - val connection: ToolkitConnection?, - val latencyContext: LatencyContext, - val customizationArn: String?, - val workspaceId: String?, -) - -data class ResponseContext( - val sessionId: String, -) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt deleted file mode 100644 index dadbeb2f8dc..00000000000 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt +++ /dev/null @@ -1,665 +0,0 @@ -// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.codewhisperer.service - -import com.intellij.codeInsight.hint.HintManager -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationInfo -import com.intellij.openapi.application.ReadAction -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.application.runReadAction -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.VisualPosition -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.util.Key -import com.intellij.psi.PsiDocumentManager -import com.intellij.psi.PsiFile -import com.intellij.util.concurrency.annotations.RequiresEdt -import com.intellij.util.messages.Topic -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.eclipse.lsp4j.Position -import org.eclipse.lsp4j.TextDocumentIdentifier -import org.eclipse.lsp4j.jsonrpc.messages.Either -import software.amazon.awssdk.core.exception.SdkServiceException -import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException -import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException -import software.aws.toolkits.core.utils.debug -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.info -import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext -import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection -import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionContext -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionListWithReferences -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionTriggerKind -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionWithReferencesParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString -import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManagerNew -import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.getCaretPosition -import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager -import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled -import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition -import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext -import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo -import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContextNew -import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext -import software.aws.toolkits.jetbrains.services.codewhisperer.model.PreviewContext -import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationContextNew -import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContextNew -import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo -import software.aws.toolkits.jetbrains.services.codewhisperer.model.WorkerContextNew -import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManagerNew -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CaretMovement -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeInsightsSettingsFacade -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getCompletionType -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyErrorCodeWhispererUsageLimit -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.promptReAuth -import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileContextProvider -import software.aws.toolkits.jetbrains.utils.isInjectedText -import software.aws.toolkits.jetbrains.utils.isQExpired -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.CodewhispererTriggerType -import java.util.concurrent.TimeUnit - -@Service -class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { - private val codeInsightSettingsFacade = CodeInsightsSettingsFacade() - private var refreshFailure: Int = 0 - private val ongoingRequests = mutableMapOf() - val ongoingRequestsContext = mutableMapOf() - private var jobId = 0 - private var sessionContext: SessionContextNew? = null - - init { - Disposer.register(this, codeInsightSettingsFacade) - } - - private var job: Job? = null - fun showRecommendationsInPopup( - editor: Editor, - triggerTypeInfo: TriggerTypeInfo, - latencyContext: LatencyContext, - ): Job? { - if (job == null || job?.isCompleted == true) { - job = cs.launch(getCoroutineBgContext()) { - doShowRecommendationsInPopup(editor, triggerTypeInfo, latencyContext) - } - } - - // did some wrangling, but compiler didn't believe this can't be null - return job - } - - private suspend fun doShowRecommendationsInPopup( - editor: Editor, - triggerTypeInfo: TriggerTypeInfo, - latencyContext: LatencyContext, - ) { - val project = editor.project ?: return - if (!isCodeWhispererEnabled(project)) return - - // try to refresh automatically if possible, otherwise ask user to login again - if (isQExpired(project)) { - // consider changing to only running once a ~minute since this is relatively expensive - // say the connection is un-refreshable if refresh fails for 3 times - val shouldReauth = if (refreshFailure < MAX_REFRESH_ATTEMPT) { - val attempt = withContext(getCoroutineBgContext()) { - promptReAuth(project) - } - - if (!attempt) { - refreshFailure++ - } - - attempt - } else { - true - } - - if (shouldReauth) { - return - } - } - - val psiFile = runReadAction { PsiDocumentManager.getInstance(project).getPsiFile(editor.document) } - - if (psiFile == null) { - LOG.debug { "No PSI file for the current document" } - if (triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - showCodeWhispererInfoHint(editor, message("codewhisperer.trigger.document.unsupported")) - } - return - } - val isInjectedFile = runReadAction { psiFile.isInjectedText() } - if (isInjectedFile) return - - val currentJobId = jobId++ - val requestContext = try { - getRequestContext(triggerTypeInfo, editor, project, psiFile) - } catch (e: Exception) { - LOG.debug { e.message.toString() } - return - } - val caretContext = requestContext.fileContextInfo.caretContext - ongoingRequestsContext.forEach { (k, v) -> - val vCaretContext = v.fileContextInfo.caretContext - if (vCaretContext == caretContext) { - LOG.debug { "same caretContext found from job: $k, left context ${vCaretContext.leftContextOnCurrentLine}, jobId: $currentJobId" } - return - } - } - - LOG.debug { - "Calling CodeWhisperer service, jobId: $currentJobId, trigger type: ${triggerTypeInfo.triggerType}" + - if (triggerTypeInfo.triggerType == CodewhispererTriggerType.AutoTrigger) { - ", auto-trigger type: ${triggerTypeInfo.automatedTriggerType}" - } else { - "" - } - } - - CodeWhispererInvocationStatusNew.getInstance().startInvocation() - - invokeCodeWhispererInBackground(requestContext, currentJobId, latencyContext) - } - - internal suspend fun invokeCodeWhispererInBackground(requestContext: RequestContextNew, currentJobId: Int, latencyContext: LatencyContext) { - ongoingRequestsContext[currentJobId] = requestContext - val sessionContext = sessionContext ?: SessionContextNew(requestContext.project, requestContext.editor, latencyContext = latencyContext) - - // In rare cases when there's an ongoing session and subsequent triggers are from a different project or editor -- - // we will cancel the existing session(since we've already moved to a different project or editor simply return. - if (requestContext.project != sessionContext.project || requestContext.editor != sessionContext.editor) { - disposeDisplaySession(false) - return - } - this.sessionContext = sessionContext - - val workerContexts = mutableListOf() - - // When session is disposed we will cancel this coroutine. The only places session can get disposed should be - // from CodeWhispererService.disposeDisplaySession(). - // It's possible and ok that coroutine will keep running until the next time we check it's state. - // As long as we don't show to the user extra info we are good. - var lastRecommendationIndex = -1 - - try { - var startTime = System.nanoTime() - CodeWhispererInvocationStatusNew.getInstance().setInvocationStart() - var requestCount = 0 - var nextToken: Either? = null - do { - val result = AmazonQLspService.executeAsyncIfRunning(requestContext.project) { server -> - val params = createInlineCompletionParams(requestContext.editor, requestContext.triggerTypeInfo, nextToken) - server.inlineCompletionWithReferences(params) - } - result?.thenAccept { completion -> - nextToken = completion.partialResultToken - requestCount++ - val endTime = System.nanoTime() - val latency = TimeUnit.NANOSECONDS.toMillis(endTime - startTime).toDouble() - startTime = endTime - val responseContext = ResponseContext(completion.sessionId) - logServiceInvocation(requestContext, responseContext, completion, latency, null) - lastRecommendationIndex += completion.items.size - - runInEdt { - // If delay is not met, add them to the worker queue and process them later. - // On first response, workers queue must be empty. If there's enough delay before showing, - // process CodeWhisperer UI rendering and workers queue will remain empty throughout this - // CodeWhisperer session. If there's not enough delay before showing, the CodeWhisperer UI rendering task - // will be added to the workers queue. - // On subsequent responses, if they see workers queue is not empty, it means the first worker - // task hasn't been finished yet, in this case simply add another task to the queue. If they - // see worker queue is empty, the previous tasks must have been finished before this. In this - // case render CodeWhisperer UI directly. - val workerContext = WorkerContextNew(requestContext, responseContext, completion) - if (workerContexts.isNotEmpty()) { - workerContexts.add(workerContext) - } else { - if (ongoingRequests.values.filterNotNull().isEmpty() && - !CodeWhispererInvocationStatusNew.getInstance().hasEnoughDelayToShowCodeWhisperer() - ) { - // It's the first response, and no enough delay before showing - projectCoroutineScope(requestContext.project).launch { - while (!CodeWhispererInvocationStatusNew.getInstance().hasEnoughDelayToShowCodeWhisperer()) { - delay(CodeWhispererConstants.POPUP_DELAY_CHECK_INTERVAL) - } - runInEdt { - workerContexts.forEach { - processCodeWhispererUI( - sessionContext, - it, - ongoingRequests[currentJobId], - cs, - currentJobId - ) - if (!ongoingRequests.contains(currentJobId)) { - job?.cancel() - } - } - workerContexts.clear() - } - } - workerContexts.add(workerContext) - } else { - // Have enough delay before showing for the first response, or it's subsequent responses - processCodeWhispererUI( - sessionContext, - workerContext, - ongoingRequests[currentJobId], - cs, - currentJobId - ) - if (!ongoingRequests.contains(currentJobId)) { - job?.cancel() - } - } - } - } - if (!cs.isActive) { - // If job is cancelled before we do another request, don't bother making - // another API call to save resources - LOG.debug { "Skipping sending remaining requests on inactive CodeWhisperer session exit" } - return@thenAccept - } - if (requestCount >= PAGINATION_REQUEST_COUNT_ALLOWED) { - LOG.debug { "Only $PAGINATION_REQUEST_COUNT_ALLOWED request per pagination session for now" } - CodeWhispererInvocationStatusNew.getInstance().finishInvocation() - return@thenAccept - } - } - } while (nextToken != null) - } catch (e: Exception) { - val requestId: String - val sessionId: String - val displayMessage: String - - if (e is CodeWhispererRuntimeException) { - requestId = e.requestId().orEmpty() - sessionId = e.awsErrorDetails().sdkHttpResponse().headers().getOrDefault(KET_SESSION_ID, listOf(requestId))[0] - displayMessage = e.awsErrorDetails().errorMessage() ?: message("codewhisperer.trigger.error.server_side") - } else { - sessionId = "" - val statusCode = if (e is SdkServiceException) e.statusCode() else 0 - displayMessage = - if (statusCode >= 500) { - message("codewhisperer.trigger.error.server_side") - } else { - message("codewhisperer.trigger.error.client_side") - } - if (statusCode < 500) { - LOG.debug(e) { "Error invoking CodeWhisperer service" } - } - } - val exceptionType = e::class.simpleName - val responseContext = ResponseContext(sessionId) - CodeWhispererInvocationStatusNew.getInstance().setInvocationSessionId(sessionId) - logServiceInvocation(requestContext, responseContext, null, null, exceptionType) - - if (e is ThrottlingException && - e.message == CodeWhispererConstants.THROTTLING_MESSAGE - ) { - CodeWhispererExplorerActionManager.getInstance().setSuspended(requestContext.project) - if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - notifyErrorCodeWhispererUsageLimit(requestContext.project) - } - } else { - if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - // We should only show error hint when CodeWhisperer popup is not visible, - // and make it silent if CodeWhisperer popup is showing. - runInEdt { - if (!CodeWhispererInvocationStatusNew.getInstance().isDisplaySessionActive()) { - showCodeWhispererErrorHint(requestContext.editor, displayMessage) - } - } - } - } - CodeWhispererInvocationStatusNew.getInstance().finishInvocation() - runInEdt { - CodeWhispererPopupManagerNew.getInstance().updatePopupPanel(sessionContext) - } - } - } - - @RequiresEdt - private fun processCodeWhispererUI( - sessionContext: SessionContextNew, - workerContext: WorkerContextNew, - currStates: InvocationContextNew?, - coroutine: CoroutineScope, - jobId: Int, - ) { - val requestContext = workerContext.requestContext - val responseContext = workerContext.responseContext - val completions = workerContext.completions - - // At this point when we are in EDT, the state of the popup will be thread-safe - // across this thread execution, so if popup is disposed, we will stop here. - // This extra check is needed because there's a time between when we get the response and - // when we enter the EDT. - if (!coroutine.isActive || sessionContext.isDisposed()) { - LOG.debug { "Stop showing CodeWhisperer recommendations on CodeWhisperer session exit. session id: ${completions.sessionId}, jobId: $jobId" } - return - } - - if (requestContext.editor.isDisposed) { - LOG.debug { "Stop showing all CodeWhisperer recommendations since editor is disposed. session id: ${completions.sessionId}, jobId: $jobId" } - disposeDisplaySession(false) - return - } - - CodeWhispererInvocationStatusNew.getInstance().finishInvocation() - - val caretMovement = CodeWhispererEditorManagerNew.getInstance().getCaretMovement( - requestContext.editor, - requestContext.caretPosition - ) - val isPopupShowing = checkRecommendationsValidity(currStates, false) - val nextStates: InvocationContextNew? - if (currStates == null) { - // first response for the jobId - nextStates = initStates(jobId, requestContext, responseContext, completions, caretMovement) - - // receiving a null state means caret has moved backward, - // so we are going to cancel the current job - if (nextStates == null) { - return - } - } else { - // subsequent responses for the jobId - nextStates = updateStates(currStates, completions) - } - LOG.debug { "Adding ${completions.items.size} completions to the session. session id: ${completions.sessionId}, jobId: $jobId" } - - // TODO: may have bug when it's a mix of auto-trigger + manual trigger - val hasAtLeastOneValid = checkRecommendationsValidity(nextStates, true) - val allSuggestions = ongoingRequests.values.filterNotNull().flatMap { it.recommendationContext.details } - val valid = allSuggestions.count { !it.isDiscarded } - LOG.debug { "Suggestions status: valid: $valid, discarded: ${allSuggestions.size - valid}" } - - // If there are no recommendations at all in this session, we need to manually send the user decision event here - // since it won't be sent automatically later - // TODO: may have bug; visit later - if (nextStates.recommendationContext.details.isEmpty()) { - LOG.debug { "Received just an empty list from this session. session id: ${completions.sessionId}" } - } - if (!hasAtLeastOneValid) { - LOG.debug { "None of the recommendations are valid, exiting current CodeWhisperer pagination session" } - // If there's only one ongoing request, after disposing this, the entire session will also end - if (ongoingRequests.keys.size == 1) { - disposeDisplaySession(false) - } else { - disposeJob(jobId) - sessionContext.selectedIndex = CodeWhispererPopupManagerNew.getInstance().findNewSelectedIndex(true, sessionContext.selectedIndex) - } - } else { - updateCodeWhisperer(sessionContext, nextStates, isPopupShowing) - } - } - - private fun initStates( - jobId: Int, - requestContext: RequestContextNew, - responseContext: ResponseContext, - completions: InlineCompletionListWithReferences, - caretMovement: CaretMovement, - ): InvocationContextNew? { - val visualPosition = requestContext.editor.caretModel.visualPosition - - if (caretMovement == CaretMovement.MOVE_BACKWARD) { - LOG.debug { "Caret moved backward, discarding all of the recommendations. Session Id: ${completions.sessionId}, jobId: $jobId" } - val detailContexts = completions.items.map { - DetailContext("", it, true, getCompletionType(it)) - }.toMutableList() - val recommendationContext = RecommendationContextNew(detailContexts, "", VisualPosition(0, 0), jobId) - ongoingRequests[jobId] = buildInvocationContext(requestContext, responseContext, recommendationContext) - disposeDisplaySession(false) - return null - } - - val userInput = - if (caretMovement == CaretMovement.NO_CHANGE) { - LOG.debug { "Caret position not changed since invocation. Session Id: ${completions.sessionId}" } - "" - } else { - LOG.debug { "Caret position moved forward since invocation. Session Id: ${completions.sessionId}" } - CodeWhispererEditorManagerNew.getInstance().getUserInputSinceInvocation( - requestContext.editor, - requestContext.caretPosition.offset - ) - } - val detailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( - userInput, - completions, - ) - val recommendationContext = RecommendationContextNew(detailContexts, userInput, visualPosition, jobId) - ongoingRequests[jobId] = buildInvocationContext(requestContext, responseContext, recommendationContext) - return ongoingRequests[jobId] - } - - private fun updateStates( - states: InvocationContextNew, - completions: InlineCompletionListWithReferences, - ): InvocationContextNew { - val recommendationContext = states.recommendationContext - val newDetailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( - recommendationContext.userInput, - completions, - ) - - recommendationContext.details.addAll(newDetailContexts) - return states - } - - private fun checkRecommendationsValidity(states: InvocationContextNew?, showHint: Boolean): Boolean { - if (states == null) return false - val details = states.recommendationContext.details - - // set to true when at least one is not discarded or empty - val hasAtLeastOneValid = details.any { !it.isDiscarded && it.completion.insertText.isNotEmpty() } - - if (!hasAtLeastOneValid && showHint && states.requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { - showCodeWhispererInfoHint( - states.requestContext.editor, - message("codewhisperer.popup.no_recommendations") - ) - } - return hasAtLeastOneValid - } - - private fun updateCodeWhisperer(sessionContext: SessionContextNew, states: InvocationContextNew, recommendationAdded: Boolean) { - CodeWhispererPopupManagerNew.getInstance().changeStatesForShowing(sessionContext, states, recommendationAdded) - } - - @RequiresEdt - private fun disposeJob(jobId: Int) { - ongoingRequests[jobId]?.let { Disposer.dispose(it) } - ongoingRequests.remove(jobId) - ongoingRequestsContext.remove(jobId) - } - - @RequiresEdt - fun disposeDisplaySession(accept: Boolean) { - // avoid duplicate session disposal logic - if (sessionContext == null || sessionContext?.isDisposed() == true) return - - sessionContext?.let { - it.hasAccepted = accept - Disposer.dispose(it) - } - sessionContext = null - val jobIds = ongoingRequests.keys.toList() - jobIds.forEach { jobId -> disposeJob(jobId) } - ongoingRequests.clear() - ongoingRequestsContext.clear() - } - - fun getAllSuggestionsPreviewInfo() = - ongoingRequests.values.filterNotNull().flatMap { element -> - val context = element.recommendationContext - context.details.map { - PreviewContext(context.jobId, it, context.userInput, context.typeahead) - } - } - - fun getAllPaginationSessions() = ongoingRequests - - fun getRequestContext( - triggerTypeInfo: TriggerTypeInfo, - editor: Editor, - project: Project, - psiFile: PsiFile, - ): RequestContextNew { - // 1. file context - val fileContext: FileContextInfo = runReadAction { FileContextProvider.getInstance(project).extractFileContext(editor, psiFile) } - - // 3. caret position - val caretPosition = runReadAction { getCaretPosition(editor) } - - // 4. connection - val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) - - return RequestContextNew(project, editor, triggerTypeInfo, caretPosition, fileContext, connection) - } - - private fun buildInvocationContext( - requestContext: RequestContextNew, - responseContext: ResponseContext, - recommendationContext: RecommendationContextNew, - ): InvocationContextNew { - // Creating a disposable for managing all listeners lifecycle attached to the popup. - // previously(before pagination) we use popup as the parent disposable. - // After pagination, listeners need to be updated as states are updated, for the same popup, - // so disposable chain becomes popup -> disposable -> listeners updates, and disposable gets replaced on every - // state update. - val states = InvocationContextNew(requestContext, responseContext, recommendationContext) - Disposer.register(states) { - job?.cancel(CancellationException("Cancelling the current coroutine when the pagination session context is disposed")) - } - return states - } - - private fun createInlineCompletionParams( - editor: Editor, - triggerTypeInfo: TriggerTypeInfo, - nextToken: Either?, - ): InlineCompletionWithReferencesParams = - ReadAction.compute { - InlineCompletionWithReferencesParams( - context = InlineCompletionContext( - // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind - triggerKind = when (triggerTypeInfo.triggerType) { - CodewhispererTriggerType.OnDemand -> InlineCompletionTriggerKind.Invoke - CodewhispererTriggerType.AutoTrigger -> InlineCompletionTriggerKind.Automatic - else -> InlineCompletionTriggerKind.Invoke - } - ), - documentChangeParams = null, - openTabFilepaths = null, - ).apply { - textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile)) - position = Position( - editor.caretModel.primaryCaret.logicalPosition.line, - editor.caretModel.primaryCaret.logicalPosition.column - ) - if (nextToken != null) { - workDoneToken = nextToken - } - } - } - - private fun logServiceInvocation( - requestContext: RequestContextNew, - responseContext: ResponseContext, - completions: InlineCompletionListWithReferences?, - latency: Double?, - exceptionType: String?, - ) { - val recommendationLogs = completions?.items?.map { it.insertText.trimEnd() } - ?.reduceIndexedOrNull { index, acc, recommendation -> "$acc\n[${index + 1}]\n$recommendation" } - LOG.info { - "SessionId: ${responseContext.sessionId}, " + - "Jetbrains IDE: ${ApplicationInfo.getInstance().fullApplicationName}, " + - "IDE version: ${ApplicationInfo.getInstance().apiVersion}, " + - "Filename: ${requestContext.fileContextInfo.filename}, " + - "Left context of current line: ${requestContext.fileContextInfo.caretContext.leftContextOnCurrentLine}, " + - "Cursor line: ${requestContext.caretPosition.line}, " + - "Caret offset: ${requestContext.caretPosition.offset}, " + - latency?.let { "Latency: $latency, " }.orEmpty() + - exceptionType?.let { "Exception Type: $it, " }.orEmpty() + - "Recommendations: \n${recommendationLogs ?: "None"}" - } - } - - fun canDoInvocation(editor: Editor, type: CodewhispererTriggerType): Boolean { - editor.project?.let { - if (!isCodeWhispererEnabled(it)) { - return false - } - } - - if (type == CodewhispererTriggerType.AutoTrigger && !CodeWhispererExplorerActionManager.getInstance().isAutoEnabled()) { - LOG.debug { "CodeWhisperer auto-trigger is disabled, not invoking service" } - return false - } - - if (CodeWhispererInvocationStatusNew.getInstance().isDisplaySessionActive()) { - LOG.debug { "Find an existing CodeWhisperer session before triggering CodeWhisperer, not invoking service" } - return false - } - return true - } - - fun showCodeWhispererInfoHint(editor: Editor, message: String) { - HintManager.getInstance().showInformationHint(editor, message, HintManager.UNDER) - } - - fun showCodeWhispererErrorHint(editor: Editor, message: String) { - HintManager.getInstance().showErrorHint(editor, message, HintManager.UNDER) - } - - override fun dispose() {} - - companion object { - private val LOG = getLogger() - private const val MAX_REFRESH_ATTEMPT = 3 - private const val PAGINATION_REQUEST_COUNT_ALLOWED = 1 - - val CODEWHISPERER_INTELLISENSE_POPUP_ON_HOVER: Topic = Topic.create( - "CodeWhisperer intelliSense popup on hover", - CodeWhispererIntelliSenseOnHoverListener::class.java - ) - val KEY_SESSION_CONTEXT = Key.create("codewhisperer.session") - - fun getInstance(): CodeWhispererServiceNew = service() - const val KET_SESSION_ID = "x-amzn-SessionId" - } -} - -data class RequestContextNew( - val project: Project, - val editor: Editor, - val triggerTypeInfo: TriggerTypeInfo, - val caretPosition: CaretPosition, - val fileContextInfo: FileContextInfo, - val connection: ToolkitConnection?, -) - -interface CodeWhispererIntelliSenseOnHoverListener { - fun onEnter() {} -} diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt diff --git a/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt deleted file mode 100644 index 9a211e8b7f6..00000000000 --- a/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt +++ /dev/null @@ -1,605 +0,0 @@ -// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -@file:Suppress("BannedImports") - -package software.aws.toolkits.jetbrains.services.amazonq.lsp - -import com.intellij.diff.DiffContentFactory -import com.intellij.diff.requests.SimpleDiffRequest -import com.intellij.ide.BrowserUtil -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationType -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.fileChooser.FileChooser -import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory -import com.intellij.openapi.fileChooser.FileChooserFactory -import com.intellij.openapi.fileChooser.FileSaverDescriptor -import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.openapi.vfs.VfsUtil -import com.intellij.openapi.vfs.VfsUtilCore -import com.intellij.openapi.vfs.VirtualFileManager -import migration.software.aws.toolkits.jetbrains.settings.AwsSettings -import org.eclipse.lsp4j.ApplyWorkspaceEditParams -import org.eclipse.lsp4j.ApplyWorkspaceEditResponse -import org.eclipse.lsp4j.ConfigurationParams -import org.eclipse.lsp4j.MessageActionItem -import org.eclipse.lsp4j.MessageParams -import org.eclipse.lsp4j.MessageType -import org.eclipse.lsp4j.ProgressParams -import org.eclipse.lsp4j.PublishDiagnosticsParams -import org.eclipse.lsp4j.ShowDocumentParams -import org.eclipse.lsp4j.ShowDocumentResult -import org.eclipse.lsp4j.ShowMessageRequestParams -import org.eclipse.lsp4j.TextDocumentIdentifier -import org.eclipse.lsp4j.jsonrpc.ResponseErrorException -import org.eclipse.lsp4j.jsonrpc.messages.ResponseError -import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode -import org.slf4j.event.Level -import software.amazon.awssdk.utils.UserHomeDirectoryUtils -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.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection -import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.ChatCommunicationManager -import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.FlareUiMessage -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LSPAny -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_OPEN_TAB -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_OPTIONS_UPDATE_NOTIFICATION -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_PINNED_CONTEXT_ADD -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_PINNED_CONTEXT_REMOVE -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_SEND_CONTEXT_COMMANDS -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_SEND_PINNED_CONTEXT -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_SEND_UPDATE -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CopyFileParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.FileParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GET_SERIALIZED_CHAT_REQUEST_METHOD -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GetSerializedChatResult -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OpenFileDiffParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowOpenFileDialogParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogParams -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogResult -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.TelemetryParsingUtil -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.applyExtensionFilter -import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator -import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService -import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings -import software.aws.toolkits.jetbrains.utils.getCleanedContent -import software.aws.toolkits.jetbrains.utils.notify -import software.aws.toolkits.resources.message -import java.io.File -import java.net.URLDecoder -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Paths -import java.util.UUID -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit - -/** - * Concrete implementation of [AmazonQLanguageClient] to handle messages sent from server - */ -class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageClient { - private val chatManager - get() = ChatCommunicationManager.getInstance(project) - private fun handleTelemetryMap(telemetryMap: Map<*, *>) { - try { - val name = telemetryMap["name"] as? String ?: return - - @Suppress("UNCHECKED_CAST") - val data = telemetryMap["data"] as? Map ?: return - - TelemetryService.getInstance().record(project) { - datum(name) { - unit(TelemetryParsingUtil.parseMetricUnit(telemetryMap["unit"])) - value(telemetryMap["value"] as? Double ?: 1.0) - passive(telemetryMap["passive"] as? Boolean ?: false) - - telemetryMap["result"]?.let { result -> - metadata("result", result.toString()) - } - - data.forEach { (key, value) -> - metadata(key, value?.toString() ?: "null") - } - } - } - } catch (e: Exception) { - LOG.warn(e) { "Failed to process telemetry event: $telemetryMap" } - } - } - - override fun telemetryEvent(`object`: Any) { - when (`object`) { - is Map<*, *> -> handleTelemetryMap(`object`) - else -> LOG.warn { "Unexpected telemetry event: $`object`" } - } - } - - override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) { - println(diagnostics) - } - - override fun showMessage(messageParams: MessageParams) { - notify( - messageParams.type.toNotificationType(), - message("toolwindow.stripe.amazon.q.window"), - getCleanedContent(messageParams.message, true), - project, - emptyList() - ) - } - - override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture { - val future = CompletableFuture() - if (requestParams.actions.isNullOrEmpty()) { - future.complete(null) - } - - notify( - requestParams.type.toNotificationType(), - message("toolwindow.stripe.amazon.q.window"), - getCleanedContent(requestParams.message, true), - project, - requestParams.actions.map { item -> - NotificationAction.createSimple(item.title) { - future.complete(item) - } - } - ) - - return future - } - - override fun logMessage(message: MessageParams) { - val type = when (message.type) { - MessageType.Error -> Level.ERROR - MessageType.Warning -> Level.WARN - MessageType.Info, MessageType.Log -> Level.INFO - else -> Level.WARN - } - - if (type == Level.ERROR && - message.message.lineSequence().firstOrNull()?.contains("NOTE: The AWS SDK for JavaScript (v2) is in maintenance mode.") == true - ) { - LOG.info { "Suppressed Flare AWS JS SDK v2 EoL error message" } - return - } - - LOG.atLevel(type).log(message.message) - } - - override fun showDocument(params: ShowDocumentParams): CompletableFuture { - try { - if (params.uri.isNullOrEmpty()) { - return CompletableFuture.completedFuture(ShowDocumentResult(false)) - } - - if (params.external == true) { - BrowserUtil.open(params.uri) - return CompletableFuture.completedFuture(ShowDocumentResult(true)) - } - - // The filepath sent by the server contains unicode characters which need to be - // decoded for JB file handling APIs to be handle to handle file operations - val fileToOpen = URLDecoder.decode(params.uri, StandardCharsets.UTF_8.name()) - return CompletableFuture.supplyAsync( - { - try { - val virtualFile = VirtualFileManager.getInstance().refreshAndFindFileByUrl(fileToOpen) - ?: throw IllegalArgumentException("Cannot find file: $fileToOpen") - - FileEditorManager.getInstance(project).openFile(virtualFile, true) - ShowDocumentResult(true) - } catch (e: Exception) { - LOG.warn { "Failed to show document: $fileToOpen" } - ShowDocumentResult(false) - } - }, - ApplicationManager.getApplication()::invokeLater - ) - } catch (e: Exception) { - LOG.warn { "Error showing document" } - return CompletableFuture.completedFuture(ShowDocumentResult(false)) - } - } - - override fun getConnectionMetadata(): CompletableFuture = - CompletableFuture.supplyAsync { - val connection = ToolkitConnectionManager.getInstance(project) - .activeConnectionForFeature(QConnection.getInstance()) - - connection?.let { ConnectionMetadata.fromConnection(it) } - } - - override fun openTab(params: LSPAny): CompletableFuture { - val requestId = UUID.randomUUID().toString() - val result = CompletableFuture() - chatManager.addTabOpenRequest(requestId, result) - - chatManager.notifyUi( - FlareUiMessage( - command = CHAT_OPEN_TAB, - params = params, - requestId = requestId, - ) - ) - - result.orTimeout(30000, TimeUnit.MILLISECONDS) - .whenComplete { _, error -> - chatManager.removeTabOpenRequest(requestId) - } - - return result - } - - override fun showSaveFileDialog(params: ShowSaveFileDialogParams): CompletableFuture { - val filters = mutableListOf() - val formatMappings = mapOf("markdown" to "md", "html" to "html") - - params.supportedFormats.forEach { format -> - formatMappings[format]?.let { filters.add(it) } - } - val defaultUri = params.defaultUri ?: "export-chat.md" - val saveAtUri = defaultUri.substring(defaultUri.lastIndexOf("/") + 1) - return CompletableFuture.supplyAsync( - { - val descriptor = FileSaverDescriptor("Export", "Choose a location to export").apply { - withFileFilter { file -> - filters.any { ext -> - file.name.endsWith(".$ext") - } - } - } - - val chosenFile = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project).save(saveAtUri) - - chosenFile?.let { - ShowSaveFileDialogResult(chosenFile.file.path) - } ?: throw ResponseErrorException(ResponseError(ResponseErrorCode.RequestCancelled, "Export cancelled by user", null)) - }, - ApplicationManager.getApplication()::invokeLater - ) - } - - override fun showOpenFileDialog(params: ShowOpenFileDialogParams): CompletableFuture = - CompletableFuture.supplyAsync( - { - // Handle the case where both canSelectFiles and canSelectFolders are false (should never be sent from flare) - if (!params.canSelectFiles && !params.canSelectFolders) { - return@supplyAsync mapOf("uris" to emptyList()) as LSPAny - } - - val descriptor = when { - params.canSelectFolders && params.canSelectFiles -> { - if (params.canSelectMany) { - FileChooserDescriptorFactory.createAllButJarContentsDescriptor() - } else { - FileChooserDescriptorFactory.createSingleFileOrFolderDescriptor() - } - } - params.canSelectFolders -> { - if (params.canSelectMany) { - FileChooserDescriptorFactory.createMultipleFoldersDescriptor() - } else { - FileChooserDescriptorFactory.createSingleFolderDescriptor() - } - } - else -> { - if (params.canSelectMany) { - FileChooserDescriptorFactory.createMultipleFilesNoJarsDescriptor() - } else { - FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() - } - } - }.apply { - withTitle( - params.title ?: when { - params.canSelectFolders && params.canSelectFiles -> "Select Files or Folders" - params.canSelectFolders -> "Select Folders" - else -> "Select Files" - } - ) - withDescription( - when { - params.canSelectFolders && params.canSelectFiles -> "Choose files or folders to open" - params.canSelectFolders -> "Choose folders to open" - else -> "Choose files to open" - } - ) - - // Apply file filters if provided - if (params.filters.isNotEmpty() && !params.canSelectFolders) { - // Create a combined list of all allowed extensions - val allowedExtensions = params.filters.values.flatten().toSet() - applyExtensionFilter(this, "Images", allowedExtensions) - } - } - - val chosenFiles = FileChooser.chooseFiles(descriptor, project, null) - val uris = chosenFiles.map { it.path } - - mapOf("uris" to uris) as LSPAny - }, - ApplicationManager.getApplication()::invokeLater - ) - - override fun getSerializedChat(params: LSPAny): CompletableFuture { - val requestId = UUID.randomUUID().toString() - val result = CompletableFuture() - chatManager.addSerializedChatRequest(requestId, result) - - chatManager.notifyUi( - FlareUiMessage( - command = GET_SERIALIZED_CHAT_REQUEST_METHOD, - params = params, - requestId = requestId, - ) - ) - - result.orTimeout(30000, TimeUnit.MILLISECONDS) - .whenComplete { _, error -> - chatManager.removeSerializedChatRequest(requestId) - } - - return result - } - - override fun configuration(params: ConfigurationParams): CompletableFuture> { - if (params.items.isEmpty()) { - return CompletableFuture.completedFuture(null) - } - - return CompletableFuture.completedFuture( - buildList { - val qSettings = CodeWhispererSettings.getInstance() - params.items.forEach { - when (it.section) { - AmazonQLspConstants.LSP_CW_CONFIGURATION_KEY -> { - add( - CodeWhispererLspConfiguration( - shouldShareData = qSettings.isMetricOptIn(), - shouldShareCodeReferences = qSettings.isIncludeCodeWithReference(), - // server context - shouldEnableWorkspaceContext = qSettings.isWorkspaceContextEnabled() - ) - ) - } - - AmazonQLspConstants.LSP_Q_CONFIGURATION_KEY -> { - add( - AmazonQLspConfiguration( - optOutTelemetry = !AwsSettings.getInstance().isTelemetryEnabled, - customization = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn, - // local context - projectContext = ProjectContextConfiguration( - enableLocalIndexing = qSettings.isProjectContextEnabled(), - indexWorkerThreads = qSettings.getProjectContextIndexThreadCount(), - enableGpuAcceleration = qSettings.isProjectContextGpu(), - localIndexing = LocalIndexingConfiguration( - maxIndexSizeMB = qSettings.getProjectContextIndexMaxSize() - ) - ) - ) - ) - } - } - } - } - ) - } - - override fun notifyProgress(params: ProgressParams?) { - if (params == null) return - try { - chatManager.handlePartialResultProgressNotification(project, params) - } catch (e: Exception) { - LOG.error(e) { "Cannot handle partial chat" } - } - } - - override fun sendChatUpdate(params: LSPAny) { - chatManager.notifyUi( - FlareUiMessage( - command = CHAT_SEND_UPDATE, - params = params, - ) - ) - } - - private fun File.toVirtualFile() = LocalFileSystem.getInstance().findFileByIoFile(this) - - private fun MessageType.toNotificationType() = when (this) { - MessageType.Error -> NotificationType.ERROR - MessageType.Warning -> NotificationType.WARNING - MessageType.Info, MessageType.Log -> NotificationType.INFORMATION - } - - override fun openFileDiff(params: OpenFileDiffParams) { - ApplicationManager.getApplication().invokeLater { - var tempPath: java.nio.file.Path? = null - try { - val fileName = Paths.get(params.originalFileUri).fileName.toString() - // Create a temporary virtual file for syntax highlighting - val fileExtension = fileName.substringAfterLast('.', "") - tempPath = Files.createTempFile(null, ".$fileExtension") - val virtualFile = tempPath.toFile() - .also { it.setReadOnly() } - .toVirtualFile() - - val originalContent = params.originalFileContent ?: run { - val sourceFile = File(params.originalFileUri) - if (sourceFile.exists()) sourceFile.readText() else "" - } - - val contentFactory = DiffContentFactory.getInstance() - var isNewFile = false - val (leftContent, rightContent) = when { - params.isDeleted -> { - contentFactory.create(project, originalContent, virtualFile) to - contentFactory.createEmpty() - } - - else -> { - val newContent = params.fileContent.orEmpty() - isNewFile = newContent == originalContent - when { - isNewFile -> { - contentFactory.createEmpty() to - contentFactory.create(project, newContent, virtualFile) - } - - else -> { - contentFactory.create(project, originalContent, virtualFile) to - contentFactory.create(project, newContent, virtualFile) - } - } - } - } - val diffRequest = SimpleDiffRequest( - "$fileName ${message("aws.q.lsp.client.diff_message")}", - leftContent, - rightContent, - "Original", - when { - params.isDeleted -> "Deleted" - isNewFile -> "Created" - else -> "Modified" - } - ) - - AmazonQDiffVirtualFile.openDiff(project, diffRequest) - } catch (e: Exception) { - LOG.warn { "Failed to open file diff: ${e.message}" } - } finally { - // Clean up the temporary file used for syntax highlight - try { - tempPath?.let { Files.deleteIfExists(it) } - } catch (e: Exception) { - LOG.warn { "Failed to delete temporary file: ${e.message}" } - } - } - } - } - - override fun sendContextCommands(params: LSPAny) { - chatManager.notifyUi( - FlareUiMessage( - command = CHAT_SEND_CONTEXT_COMMANDS, - params = params, - ) - ) - } - - override fun sendPinnedContext(params: LSPAny) { - // Send the active text file path with pinned context - val editor = FileEditorManager.getInstance(project).selectedTextEditor - val textDocument = editor?.let { - val relativePath = VfsUtilCore.getRelativePath(it.virtualFile, project.baseDir) - ?: it.virtualFile.path // Use absolute path if not in project - TextDocumentIdentifier(relativePath) - } - - // Create updated params with text document information - // Since params is LSPAny, we need to handle it as a generic object - val updatedParams = when (params) { - is Map<*, *> -> { - val mutableParams = params.toMutableMap() - mutableParams["textDocument"] = textDocument - mutableParams - } - else -> mapOf( - "params" to params, - "textDocument" to textDocument - ) - } - - chatManager.notifyUi( - FlareUiMessage( - command = CHAT_SEND_PINNED_CONTEXT, - params = updatedParams, - ) - ) - } - - override fun pinnedContextAdd(params: LSPAny) { - chatManager.notifyUi( - FlareUiMessage( - command = CHAT_PINNED_CONTEXT_ADD, - params = params, - ) - ) - } - - override fun pinnedContextRemove(params: LSPAny) { - chatManager.notifyUi( - FlareUiMessage( - command = CHAT_PINNED_CONTEXT_REMOVE, - params = params, - ) - ) - } - - override fun appendFile(params: FileParams) = refreshVfs(params.path) - - override fun createDirectory(params: FileParams) = refreshVfs(params.path) - - override fun removeFile(params: FileParams) = refreshVfs(params.path) - - override fun writeFile(params: FileParams) = refreshVfs(params.path) - - override fun copyFile(params: CopyFileParams) { - refreshVfs(params.oldPath) - return refreshVfs(params.newPath) - } - - override fun sendChatOptionsUpdate(params: LSPAny) { - chatManager.notifyUi( - FlareUiMessage( - command = CHAT_OPTIONS_UPDATE_NOTIFICATION, - params = params, - ) - ) - } - - override fun applyEdit(params: ApplyWorkspaceEditParams): CompletableFuture = - CompletableFuture.supplyAsync( - { - try { - LspEditorUtil.applyWorkspaceEdit(project, params.edit) - ApplyWorkspaceEditResponse(true) - } catch (e: Exception) { - LOG.warn(e) { "Failed to apply workspace edit" } - ApplyWorkspaceEditResponse(false) - } - }, - ApplicationManager.getApplication()::invokeLater - ) - - private fun refreshVfs(path: String) { - val currPath = Paths.get(path) - if (currPath.startsWith(localHistoryPath)) return - try { - ApplicationManager.getApplication().executeOnPooledThread { - VfsUtil.markDirtyAndRefresh(false, true, true, currPath.toFile()) - } - } catch (e: Exception) { - LOG.warn(e) { "Could not refresh file" } - } - } - - companion object { - val localHistoryPath = Paths.get( - UserHomeDirectoryUtils.userHomeDirectory(), - ".aws", - "amazonq", - "history" - ) - private val LOG = getLogger() - } -} diff --git a/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt deleted file mode 100644 index b49f412f987..00000000000 --- a/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument - -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.editor.Document -import com.intellij.openapi.editor.event.DocumentEvent -import com.intellij.openapi.editor.event.DocumentListener -import com.intellij.openapi.fileEditor.FileDocumentManager -import com.intellij.openapi.fileEditor.FileDocumentManagerListener -import com.intellij.openapi.fileEditor.FileEditor -import com.intellij.openapi.fileEditor.FileEditorManager -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 -import com.intellij.openapi.vfs.newvfs.BulkFileListener -import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent -import com.intellij.openapi.vfs.newvfs.events.VFileEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.eclipse.lsp4j.DidChangeTextDocumentParams -import org.eclipse.lsp4j.DidCloseTextDocumentParams -import org.eclipse.lsp4j.DidOpenTextDocumentParams -import org.eclipse.lsp4j.DidSaveTextDocumentParams -import org.eclipse.lsp4j.TextDocumentContentChangeEvent -import org.eclipse.lsp4j.TextDocumentIdentifier -import org.eclipse.lsp4j.TextDocumentItem -import org.eclipse.lsp4j.VersionedTextDocumentIdentifier -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.tryOrNull -import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer -import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ACTIVE_EDITOR_CHANGED_NOTIFICATION -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.getCursorState -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString - -class TextDocumentServiceHandler( - private val project: Project, - private val cs: CoroutineScope, -) : FileDocumentManagerListener, - FileEditorManagerListener, - BulkFileListener, - DocumentListener, - Disposable { - - init { - // didOpen & didClose events - project.messageBus.connect(this).subscribe( - FileEditorManagerListener.FILE_EDITOR_MANAGER, - this - ) - - // didChange events - project.messageBus.connect(this).subscribe( - VirtualFileManager.VFS_CHANGES, - this - ) - - // didSave events - project.messageBus.connect(this).subscribe( - FileDocumentManagerListener.TOPIC, - this - ) - - // open files on startup - cs.launch { - val fileEditorManager = FileEditorManager.getInstance(project) - fileEditorManager.selectedFiles.forEach { file -> - handleFileOpened(file) - } - } - } - - private fun handleFileOpened(file: VirtualFile) { - if (file.getUserData(KEY_REAL_TIME_EDIT_LISTENER) == null) { - val listener = object : DocumentListener { - override fun documentChanged(event: DocumentEvent) { - realTimeEdit(event) - } - } - ApplicationManager.getApplication().runReadAction { - FileDocumentManager.getInstance().getDocument(file)?.addDocumentListener(listener) - } - 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( - DidOpenTextDocumentParams().apply { - textDocument = TextDocumentItem().apply { - this.uri = uri - text = file.inputStream.readAllBytes().decodeToString() - languageId = file.fileType.name.lowercase() - version = file.modificationStamp.toInt() - } - } - ) - } - } - } - } - - override fun beforeDocumentSaving(document: Document) { - trySendIfValid { languageServer -> - val file = FileDocumentManager.getInstance().getFile(document) ?: return@trySendIfValid - toUriString(file)?.let { uri -> - languageServer.textDocumentService.didSave( - DidSaveTextDocumentParams().apply { - textDocument = TextDocumentIdentifier().apply { - this.uri = uri - } - // TODO: should respect `textDocumentSync.save.includeText` server capability config - text = document.text - } - ) - } - } - } - - override fun after(events: MutableList) { - events.filterIsInstance().forEach { event -> - val document = FileDocumentManager.getInstance().getCachedDocument(event.file) ?: return@forEach - - handleFileOpened(event.file) - trySendIfValid { languageServer -> - toUriString(event.file)?.let { uri -> - languageServer.textDocumentService.didChange( - DidChangeTextDocumentParams().apply { - textDocument = VersionedTextDocumentIdentifier().apply { - this.uri = uri - version = document.modificationStamp.toInt() - } - contentChanges = listOf( - TextDocumentContentChangeEvent().apply { - text = document.text - } - ) - } - ) - } - } - } - } - - override fun fileOpened( - source: FileEditorManager, - file: VirtualFile, - ) { - handleFileOpened(file) - } - - override fun fileClosed( - source: FileEditorManager, - file: VirtualFile, - ) { - val listener = file.getUserData(KEY_REAL_TIME_EDIT_LISTENER) - if (listener != null) { - tryOrNull { FileDocumentManager.getInstance().getDocument(file)?.removeDocumentListener(listener) } - file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, null) - - trySendIfValid { languageServer -> - toUriString(file)?.let { uri -> - languageServer.textDocumentService.didClose( - DidCloseTextDocumentParams().apply { - textDocument = TextDocumentIdentifier().apply { - this.uri = uri - } - } - ) - } - } - } - } - - override fun selectionChanged(event: FileEditorManagerEvent) { - handleActiveEditorChange(event.newEditor) - } - - private fun handleActiveEditorChange(fileEditor: FileEditor?) { - val editor = (fileEditor as? TextEditor)?.editor ?: return - editor.virtualFile?.let { handleFileOpened(it) } - - // Extract text editor if it's a TextEditor, otherwise null - val textDocumentIdentifier = TextDocumentIdentifier(toUriString(editor.virtualFile)) - val cursorState = getCursorState(editor) - - val params = mapOf( - "textDocument" to textDocumentIdentifier, - "cursorState" to cursorState - ) - - // Send notification to the language server - cs.launch { - AmazonQLspService.executeAsyncIfRunning(project) { _ -> - rawEndpoint.notify(ACTIVE_EDITOR_CHANGED_NOTIFICATION, params) - } - } - } - - private fun realTimeEdit(event: DocumentEvent) { - trySendIfValid { languageServer -> - val vFile = FileDocumentManager.getInstance().getFile(event.document) ?: return@trySendIfValid - toUriString(vFile)?.let { uri -> - languageServer.textDocumentService.didChange( - DidChangeTextDocumentParams().apply { - textDocument = VersionedTextDocumentIdentifier().apply { - this.uri = uri - version = event.document.modificationStamp.toInt() - } - contentChanges = listOf( - TextDocumentContentChangeEvent().apply { - text = event.document.text - } - ) - } - ) - } - } - // Process document changes here - } - - override fun dispose() { - } - - private fun trySendIfValid(runnable: (AmazonQLanguageServer) -> Unit) { - cs.launch { - AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> - try { - runnable(languageServer) - } catch (e: Exception) { - LOG.warn { "Invalid document: $e" } - } - } - } - } - - companion object { - private val KEY_REAL_TIME_EDIT_LISTENER = Key.create("amazonq.textdocument.realtimeedit.listener") - private val LOG = getLogger() - } -} diff --git a/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt rename to plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt diff --git a/plugins/amazonq/shared/jetbrains-community/src-253+/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 similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt rename to plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt From 186725e3a68366b8e815910a27d2497fb9c95566 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 02:34:39 -0800 Subject: [PATCH 20/35] Fix VirtualFile nullability in chat module and segregate test files - BrowserConnector.kt: Fix 2 VirtualFile null checks - LanguageExtractor.kt: Add null check with plaintext fallback - FocusAreaContextExtractor.kt: Add null check with unknown fallback - Segregate CodeInsightsSettingsFacadeTest.kt and CodeWhispererUtilTest.kt (ProjectExtension removed in 2025.3) --- .../amazonq/webview/BrowserConnector.kt | 4 +- .../context/file/util/LanguageExtractor.kt | 2 +- .../focusArea/FocusAreaContextExtractor.kt | 2 +- .../util/CodeInsightsSettingsFacadeTest.kt | 0 .../util/CodeWhispererUtilTest.kt | 0 .../util/CodeInsightsSettingsFacadeTest.kt | 121 ++++++++++++++++++ .../util/CodeWhispererUtilTest.kt | 55 ++++++++ 7 files changed, 180 insertions(+), 4 deletions(-) rename plugins/amazonq/codewhisperer/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt (100%) rename plugins/amazonq/codewhisperer/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt (100%) create mode 100644 plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt create mode 100644 plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt 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 19d1dbaba0b..65df0064c74 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 @@ -232,7 +232,7 @@ class BrowserConnector( SEND_CHAT_COMMAND_PROMPT -> { val requestFromUi = serializer.deserializeChatMessages(node) val editor = FileEditorManager.getInstance(project).selectedTextEditor - val textDocumentIdentifier = editor?.let { TextDocumentIdentifier(toUriString(it.virtualFile)) } + val textDocumentIdentifier = editor?.virtualFile?.let { TextDocumentIdentifier(toUriString(it)) } val cursorState = editor?.let { LspEditorUtil.getCursorState(it) } val enrichmentParams = mapOf( @@ -362,7 +362,7 @@ class BrowserConnector( CHAT_INSERT_TO_CURSOR -> { val editor = FileEditorManager.getInstance(project).selectedTextEditor - val textDocumentIdentifier = editor?.let { TextDocumentIdentifier(toUriString(it.virtualFile)) } + val textDocumentIdentifier = editor?.virtualFile?.let { TextDocumentIdentifier(toUriString(it)) } val cursorPosition = editor?.let { LspEditorUtil.getCursorPosition(it) } val enrichmentParams = mapOf( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt index 726edc0212d..27416587264 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt @@ -10,6 +10,6 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmi class LanguageExtractor { fun extractLanguageNameFromCurrentFile(editor: Editor): String = runReadAction { - editor.virtualFile.programmingLanguage().languageId + editor.virtualFile?.programmingLanguage()?.languageId ?: "plaintext" } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt index 210c0263c31..a6fa45db1e9 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt @@ -114,7 +114,7 @@ class FocusAreaContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter languageExtractor.extractLanguageNameFromCurrentFile(editor) } val fileText = editor.document.text - val fileName = editor.virtualFile.name + val fileName = editor.virtualFile?.name ?: "unknown" // Offset the selection range to the start of the trimmedFileText val selectionInsideTrimmedFileTextRange = codeSelectionRange.let { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt new file mode 100644 index 00000000000..1323774b3d7 --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt @@ -0,0 +1,121 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.codeInsight.CodeInsightSettings +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.replaceService +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +class CodeInsightsSettingsFacadeTest : HeavyPlatformTestCase() { + private lateinit var settings: CodeInsightSettings + private lateinit var sut: CodeInsightsSettingsFacade + + override fun setUp() { + super.setUp() + sut = spy(CodeInsightsSettingsFacade()) + settings = spy { CodeInsightSettings() } + + ApplicationManager.getApplication().replaceService( + CodeInsightSettings::class.java, + settings, + testRootDisposable + ) + } + + fun testDisableCodeInsightUntilShouldRevertWhenParentIsDisposed() { + @Suppress("ObjectLiteralToLambda") // JUnit 3 doesn't support SAM lambdas + val myFakePopup = object : Disposable { override fun dispose() {} } + Disposer.register(testRootDisposable, myFakePopup) + + // assume users' enable the following two codeinsight functionalities + settings.TAB_EXITS_BRACKETS_AND_QUOTES = true + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = true + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + + // codewhisperer disable them while popup is shown + sut.disableCodeInsightUntil(myFakePopup) + + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + assertThat(sut.pendingRevertCounts).isEqualTo(2) + + // popup is closed and disposed + Disposer.dispose(myFakePopup) + + // revert changes made by codewhisperer + verify(sut, times(2)).revertAll() + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + } + + fun testRevertAllShouldRevertBackAllChangesMadeByCodewhisperer() { + settings.TAB_EXITS_BRACKETS_AND_QUOTES = true + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = true + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + + sut.disableCodeInsightUntil(testRootDisposable) + + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + + assertThat(sut.pendingRevertCounts).isEqualTo(2) + + sut.revertAll() + assertThat(sut.pendingRevertCounts).isEqualTo(0) + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + } + + fun testDisableCodeInsightUntilShouldAlwaysFlushPendingRevertsBeforeMakingNextChanges() { + @Suppress("ObjectLiteralToLambda") // JUnit 3 doesn't support SAM lambdas + val myFakePopup = object : Disposable { override fun dispose() {} } + Disposer.register(testRootDisposable, myFakePopup) + + @Suppress("ObjectLiteralToLambda") // JUnit 3 doesn't support SAM lambdas + val myAnotherFakePopup = object : Disposable { override fun dispose() {} } + Disposer.register(testRootDisposable, myAnotherFakePopup) + + // assume users' enable the following two codeinsight functionalities + settings.TAB_EXITS_BRACKETS_AND_QUOTES = true + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = true + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + + // codewhisperer disable them while popup_1 is shown + sut.disableCodeInsightUntil(myFakePopup) + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + assertThat(sut.pendingRevertCounts).isEqualTo(2) + verify(sut, times(1)).revertAll() + + // unexpected issue happens and popup_1 is not disposed correctly and popup_2 is created + sut.disableCodeInsightUntil(myAnotherFakePopup) + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + // should still be 2 because previous ones should be reverted before preceding next changes + assertThat(sut.pendingRevertCounts).isEqualTo(2) + verify(sut, times(1 + 1)).revertAll() + + Disposer.dispose(myAnotherFakePopup) + + assertThat(sut.pendingRevertCounts).isEqualTo(0) + verify(sut, times(1 + 1 + 1)).revertAll() + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + } + + fun testDisposeShouldCallRevertAllToRevertAllChangesMadeByCodeWhisperer() { + sut.dispose() + verify(sut).revertAll() + } +} diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt new file mode 100644 index 00000000000..60c5da294d3 --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt @@ -0,0 +1,55 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.replaceService +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.credentials.ManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ReauthSource +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.reauthConnectionIfNeeded +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule + +class CodeWhispererUtilTest : HeavyPlatformTestCase() { + private val mockRegionProviderExtension = MockRegionProviderRule() + + override fun setUp() { + super.setUp() + mockRegionProviderExtension.apply( + object : org.junit.runners.model.Statement() { + override fun evaluate() {} + }, + org.junit.runner.Description.EMPTY + ).evaluate() + } + + fun testReconnectCodeWhispererRespectsConnectionSettings() { + mockkStatic(::reauthConnectionIfNeeded) + val mockConnectionManager = mockk(relaxed = true) + val mockConnection = mockk() + project.replaceService(ToolkitConnectionManager::class.java, mockConnectionManager, testRootDisposable) + ApplicationManager.getApplication().replaceService(ToolkitAuthManager::class.java, mockk(relaxed = true), testRootDisposable) + val startUrl = aString() + val region = mockRegionProviderExtension.createAwsRegion().id + val scopes = listOf(aString(), aString()) + + every { mockConnectionManager.activeConnectionForFeature(any()) } returns mockConnection + every { mockConnection.startUrl } returns startUrl + every { mockConnection.region } returns region + every { mockConnection.scopes } returns scopes + + CodeWhispererUtil.reconnectCodeWhisperer(project) + + verify { + reauthConnectionIfNeeded(project, mockConnection, isReAuth = true, reauthSource = ReauthSource.CODEWHISPERER) + } + } +} From ff004814d82a1ba721fc9b8d4aa98c65007f3d60 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 03:00:05 -0800 Subject: [PATCH 21/35] Version segregate TelemetryHelperTest.kt for 2025.3 Replace ProjectExtension with CodeInsightTestFixture approach: - Use IdeaTestFixtureFactory to create lightweight fixture - Manual initialization of mock extensions after fixture setup - Migrate test methods to JUnit naming convention (test prefix) - Add proper tearDown to clean up mocks and fixture --- .../services/amazonq/TelemetryHelperTest.kt | 0 .../services/amazonq/TelemetryHelperTest.kt | 627 ++++++++++++++++++ 2 files changed, 627 insertions(+) rename plugins/amazonq/chat/jetbrains-community/{tst => tst-242-252}/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt (100%) create mode 100644 plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt b/plugins/amazonq/chat/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt similarity index 100% rename from plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt rename to plugins/amazonq/chat/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt diff --git a/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt b/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt new file mode 100644 index 00000000000..62d74f1ccf4 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt @@ -0,0 +1,627 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.intellij.testFramework.fixtures.impl.LightTempDirTestFixtureImpl +import com.intellij.testFramework.registerServiceInstance +import com.intellij.testFramework.replaceService +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import software.amazon.awssdk.awscore.DefaultAwsResponseMetadata +import software.amazon.awssdk.awscore.util.AwsHeader.AWS_REQUEST_ID +import software.amazon.awssdk.http.SdkHttpResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ChatInteractWithMessageEvent +import software.amazon.awssdk.services.codewhispererruntime.model.ChatMessageInteractionType +import software.amazon.awssdk.services.codewhispererruntime.model.SendTelemetryEventResponse +import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.telemetry.MetricEvent +import software.aws.toolkits.core.telemetry.TelemetryBatcher +import software.aws.toolkits.jetbrains.core.MockClientManagerExtension +import software.aws.toolkits.jetbrains.core.credentials.LegacyManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.ChatSession +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.CodeNamesImpl +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FullyQualifiedName +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FullyQualifiedNames +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.TelemetryHelper +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.FileContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.FocusAreaContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.UICodeSelectionLineRange +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.UICodeSelectionRange +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.jetbrains.services.cwc.messages.IncomingCwcMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.LinkType +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionInfo +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.services.telemetry.MockTelemetryServiceExtension +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import software.aws.toolkits.telemetry.CwsprChatConversationType +import software.aws.toolkits.telemetry.CwsprChatInteractionType +import software.aws.toolkits.telemetry.CwsprChatTriggerInteraction +import software.aws.toolkits.telemetry.CwsprChatUserIntent +import kotlin.test.assertNotNull + +class TelemetryHelperTest { + private lateinit var myFixture: CodeInsightTestFixture + + // sut + private lateinit var sut: TelemetryHelper + + private lateinit var appInitContext: AmazonQAppInitContext + private lateinit var sessionStorage: ChatSessionStorage + + // dependencies + private lateinit var mockBatcher: TelemetryBatcher + private lateinit var mockClient: CodeWhispererClientAdaptor + private lateinit var mockConnectionManager: ToolkitConnectionManager + private lateinit var mockModelConfigurator: CodeWhispererModelConfigurator + + private lateinit var mockConnection: ToolkitConnection + + // Manual initialization instead of extensions to control timing + private val mockClientManager = MockClientManagerExtension() + private val mockTelemetryService = MockTelemetryServiceExtension() + + companion object { + private const val mockUrl = "mockUrl" + private const val mockRegion = "us-east-1" + private const val tabId = "tabId" + private const val messageId = "messageId" + private val userIntent = UserIntent.SHOW_EXAMPLES + private const val conversationId = "conversationId" + private const val triggerId = "triggerId" + private const val customizationArn = "customizationArn" + private const val steRequestId = "sendTelemetryEventRequestId" + private const val lang = "java" + private val mockCustomization = CodeWhispererCustomization(customizationArn, "name", "description") + private val data = ChatRequestData( + tabId = tabId, + message = "foo", + activeFileContext = ActiveFileContext( + FileContext(lang, "~/foo/bar/baz", null), + FocusAreaContext( + codeSelection = "", + codeSelectionRange = UICodeSelectionRange( + UICodeSelectionLineRange(1, 2), + UICodeSelectionLineRange(3, 4) + ), + trimmedSurroundingFileText = "", + codeNames = CodeNamesImpl( + listOf("simpleName_1"), + FullyQualifiedNames( + listOf( + FullyQualifiedName( + listOf("source_1"), + listOf("symbol_1") + ) + ) + ) + ) + ) + ), + userIntent = UserIntent.IMPROVE_CODE, + triggerType = TriggerType.Hotkeys, + customization = mockCustomization, + relevantTextDocuments = emptyList(), + useRelevantDocuments = true, + ) + private val response = ChatMessage( + tabId = tabId, + triggerId = triggerId, + messageType = ChatMessageType.Prompt, + messageId = messageId, + followUps = listOf(mock(), mock()) + ) + private val mockSteResponse = SendTelemetryEventResponse.builder() + .apply { + this.sdkHttpResponse( + SdkHttpResponse.builder().build() + ) + this.responseMetadata( + DefaultAwsResponseMetadata.create( + mapOf(AWS_REQUEST_ID to steRequestId) + ) + ) + }.build() + } + + @BeforeEach + fun setUp() { + // Create lightweight test fixture FIRST - this initializes Application + val factory = IdeaTestFixtureFactory.getFixtureFactory() + val fixtureBuilder = factory.createLightFixtureBuilder("TelemetryHelperTest") + myFixture = factory.createCodeInsightFixture(fixtureBuilder.fixture, LightTempDirTestFixtureImpl(true)) + myFixture.setUp() + + // NOW manually initialize mocks - Application exists now + mockClientManager.beforeEach(null) + mockTelemetryService.beforeEach(null) + + // Enable telemetry for tests + software.aws.toolkits.jetbrains.settings.AwsSettings.getInstance().isTelemetryEnabled = true + + // set up sut + appInitContext = AmazonQAppInitContext( + project = myFixture.project, + messagesFromAppToUi = mock(), + messagesFromUiToApp = mock(), + messageTypeRegistry = mock(), + fqnWebviewAdapter = mock() + ) + val mockSession = mock { + on { this.conversationId } doReturn conversationId + } + sessionStorage = mock { + on { this.getSession(eq(tabId)) } doReturn ChatSessionInfo(session = mockSession, scope = mock(), history = mutableListOf()) + } + sut = TelemetryHelper(appInitContext.project, sessionStorage) + + // set up client + mockClientManager.create() + + // set up connection + mockConnection = LegacyManagedBearerSsoConnection( + mockUrl, + mockRegion, + Q_SCOPES, + mock() + ) + mockConnectionManager = mock { + on { activeConnectionForFeature(eq(QConnection.getInstance())) } doReturn mockConnection + } + myFixture.project.replaceService(ToolkitConnectionManager::class.java, mockConnectionManager, myFixture.testRootDisposable) + + // set up telemetry service + mockBatcher = mockTelemetryService.batcher() + + // set up client + mockClient = mock() + myFixture.project.registerServiceInstance(CodeWhispererClientAdaptor::class.java, mockClient) + + // set up customization + mockModelConfigurator = mock { + on { activeCustomization(myFixture.project) } doReturn mockCustomization + } + ApplicationManager.getApplication().registerServiceInstance(CodeWhispererModelConfigurator::class.java, mockModelConfigurator) + } + + @AfterEach + fun tearDown() { + // Clean up mocks first + mockTelemetryService.afterEach(null) + mockClientManager.afterEach(null) + + // Then tear down fixture + myFixture.tearDown() + } + + @Test + fun testRecordAddMessage() { + mockClient.stub { + on { + sendChatAddMessageTelemetry(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } doReturn mockSteResponse + } + + // set up request data + val responseLength = 10 + val statusCode = 400 + val numberOfCodeBlocks = 1 + + sut.recordAddMessage( + data = data, + response = response, + responseLength = responseLength, + statusCode = statusCode, + numberOfCodeBlocks = numberOfCodeBlocks + ) + + // Q STE + verify(mockClient).sendChatAddMessageTelemetry( + sessionId = eq(conversationId), + requestId = eq(messageId), + userIntent = eq(software.amazon.awssdk.services.codewhispererruntime.model.UserIntent.fromValue(data.userIntent?.name)), + hasCodeSnippet = any(), + programmingLanguage = eq(lang), + activeEditorTotalCharacters = eq(data.activeFileContext.focusAreaContext?.codeSelection?.length), + timeToFirstChunkMilliseconds = eq(sut.getResponseStreamTimeToFirstChunk(tabId)), + timeBetweenChunks = eq(sut.getResponseStreamTimeBetweenChunks(tabId)), + fullResponselatency = any(), // TODO + requestLength = eq(data.message.length), + responseLength = eq(responseLength), + numberOfCodeBlocks = eq(numberOfCodeBlocks), + hasProjectLevelContext = eq(CodeWhispererSettings.getInstance().isProjectContextEnabled()), + customization = eq(mockCustomization) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_addMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversation id doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == "messageId" }, "message id doesn't match") + .matches( + { it.metadata["cwsprChatTriggerInteraction"] == CwsprChatTriggerInteraction.ContextMenu.toString() }, + "trigger type doesn't match" + ) + .matches({ it.metadata["cwsprChatUserIntent"] == CwsprChatUserIntent.ImproveCode.toString() }, "user intent doesn't match") + .matches({ + it.metadata["cwsprChatHasCodeSnippet"] == ( + data.activeFileContext.focusAreaContext?.codeSelection?.isNotEmpty() + ?: false + ).toString() + }, "has code snippet doesn't match") + .matches({ it.metadata["cwsprChatProgrammingLanguage"] == "java" }, "language doesn't match") + .matches( + { it.metadata["cwsprChatActiveEditorTotalCharacters"] == data.activeFileContext.focusAreaContext?.codeSelection?.length?.toString() }, + "total characters doesn't match" + ) + .matches( + { + it.metadata["cwsprChatActiveEditorImportCount"] == + data.activeFileContext.focusAreaContext?.codeNames?.fullyQualifiedNames?.used?.size?.toString() + }, + "import count doesn't match" + ) + .matches( + { it.metadata["cwsprChatResponseCodeSnippetCount"] == numberOfCodeBlocks.toString() }, + "number of code blocks doesn't match" + ) + .matches({ it.metadata["cwsprChatResponseCode"] == statusCode.toString() }, "response code doesn't match") + .matches( + { it.metadata["cwsprChatSourceLinkCount"] == response.relatedSuggestions?.size?.toString() }, + "source link count doesn't match" + ) + .matches({ it.metadata["cwsprChatFollowUpCount"] == response.followUps?.size?.toString() }, "follow up count doesn't match") + .matches( + { it.metadata["cwsprChatTimeToFirstChunk"] == sut.getResponseStreamTimeToFirstChunk(response.tabId).toInt().toString() }, + "time to first chunk doesn't match" + ) + .matches({ + it.metadata["cwsprChatTimeBetweenChunks"] == "[${ + sut.getResponseStreamTimeBetweenChunks(response.tabId).joinToString(", ") + }]" + }, "time between chunks doesn't match") + .matches({ it.metadata["cwsprChatRequestLength"] == data.message.length.toString() }, "request length doesn't match") + .matches({ it.metadata["cwsprChatResponseLength"] == responseLength.toString() }, "response length doesn't match") + .matches( + { it.metadata["cwsprChatConversationType"] == CwsprChatConversationType.Chat.toString() }, + "conversation type doesn't match" + ) + .matches({ it.metadata["codewhispererCustomizationArn"] == "customizationArn" }, "user intent doesn't match") + .matches({ + it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() + }, "customization description doesn't match") +// .matches({ it.metadata["cwsprChatFullResponseLatency"] == "" }, "latency") TODO + } + } + + @Test + fun `test recordInteractWithMessage - ChatItemVoted`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + sut.recordInteractWithMessage(IncomingCwcMessage.ChatItemVoted(tabId, messageId, "upvote")) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.UPVOTE) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.Upvote.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "startUrl doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - FollowupClicked`() { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + runBlocking { + sut.setResponseHasProjectContext(messageId, true) + sut.recordInteractWithMessage(IncomingCwcMessage.FollowupClicked(mock(), tabId, messageId, "command", "tabType")) + } + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.CLICK_FOLLOW_UP) + customizationArn(customizationArn) + hasProjectLevelContext(true) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.ClickFollowUp.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "startUrl doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == "true" }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - CopyCodeToClipboard`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + val codeBlockIndex = 1 + val totalCodeBlocks = 10 + + sut.recordInteractWithMessage( + IncomingCwcMessage.CopyCodeToClipboard( + "command", + tabId, + messageId, + userIntent, + "println()", + "insertionTargetType", + "eventId", + codeBlockIndex, + totalCodeBlocks, + lang + ) + ) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.COPY_SNIPPET) + interactionTarget("insertionTargetType") + acceptedCharacterCount("println()".length) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.CopySnippet.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["cwsprChatAcceptedCharactersLength"] == "println()".length.toString() }, "acceptedCharLength doesn't match") + .matches({ it.metadata["cwsprChatInteractionTarget"] == "insertionTargetType" }, "insertionTargetType doesn't match") + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "startUrl doesn't match") + .matches({ it.metadata["cwsprChatCodeBlockIndex"] == codeBlockIndex.toString() }, "cwsprChatCodeBlockIndex doesn't match") + .matches({ it.metadata["cwsprChatTotalCodeBlocks"] == totalCodeBlocks.toString() }, "cwsprChatTotalCodeBlocks doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - InsertCodeAtCursorPosition`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + val codeBlockIndex = 1 + val totalCodeBlocks = 10 + val inserTionTargetType = "insertionTargetType" + val eventId = "eventId" + val code = "println()" + + sut.recordInteractWithMessage( + IncomingCwcMessage.InsertCodeAtCursorPosition( + tabId, + messageId, + userIntent, + code, + inserTionTargetType, + emptyList(), + eventId, + codeBlockIndex, + totalCodeBlocks, + lang + ) + ) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.INSERT_AT_CURSOR) + interactionTarget(inserTionTargetType) + acceptedCharacterCount(code.length) + acceptedLineCount(code.lines().size) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)).matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.InsertAtCursor.toString() }, + "interaction type doesn't match" + ) + .matches( + { it.metadata["cwsprChatAcceptedCharactersLength"] == code.length.toString() }, + "cwsprChatAcceptedCharactersLength doesn't match" + ) + .matches( + { it.metadata["cwsprChatAcceptedNumberOfLines"] == code.lines().size.toString() }, + "cwsprChatAcceptedNumberOfLines doesn't match" + ) + .matches({ it.metadata["cwsprChatInteractionTarget"] == inserTionTargetType }, "cwsprChatInteractionTarget doesn't match") + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "credentialStartUrl doesn't match") + .matches({ it.metadata["cwsprChatCodeBlockIndex"] == codeBlockIndex.toString() }, "cwsprChatCodeBlockIndex doesn't match") + .matches({ it.metadata["cwsprChatTotalCodeBlocks"] == totalCodeBlocks.toString() }, "cwsprChatTotalCodeBlocks doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - ClickedLink`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + val link = "https://foo.bar.com" + sut.recordInteractWithMessage( + IncomingCwcMessage.ClickedLink( + LinkType.SourceLink, + tabId, + messageId, + link + ) + ) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.CLICK_LINK) + interactionTarget(link) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)).matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.ClickLink.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["cwsprChatInteractionTarget"] == link }, "cwsprChatInteractionTarget doesn't match") + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "credentialStartUrl doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - ChatItemFeedback`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + val selectedOption = "foo" + val comment = "bar" + + sut.recordInteractWithMessage( + IncomingCwcMessage.ChatItemFeedback( + tabId, + selectedOption, + comment, + messageId, + ) + ) + + // TODO: STE, not implemented yet + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher, times(2)).enqueue(capture()) + val event = firstValue.data.find { it.name == "feedback_result" } + assertNotNull(event) + assertThat(requireNotNull(event)).matches { it.metadata["result"] == "Succeeded" } + } + } +} From 9799fbddcc5ac3e082ba85997c4c111e40714ce9 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 03:30:57 -0800 Subject: [PATCH 22/35] Fix Windows test failure - allow Python SDK paths in VfsRootAccess Windows Python plugin scans for interpreters during test startup, needs access to C:/Program Files for PyPy detection --- .../amazonq/clients/AmazonQStreamingClientTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 20e21f46ba5..eba7633cb80 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 @@ -3,6 +3,8 @@ package software.aws.toolkits.jetbrains.services.amazonq.clients +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess import com.intellij.testFramework.RuleChain import com.intellij.testFramework.replaceService import kotlinx.coroutines.runBlocking @@ -54,6 +56,12 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { @Before override fun setup() { super.setup() + + // Allow Python paths on Windows for test environment (Python plugin scans for interpreters) + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } + amazonQStreamingClient = AmazonQStreamingClient.getInstance(projectRule.project) ssoClient = mockClientManagerRule.create() From e2dc5466c57c5a2311eadee20ecdd7d6180bf894 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 03:47:11 -0800 Subject: [PATCH 23/35] Fix detekt trailing whitespace in AmazonQStreamingClientTest --- .../services/amazonq/clients/AmazonQStreamingClientTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 eba7633cb80..6ddcc583c8c 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 @@ -56,12 +56,12 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { @Before override fun setup() { super.setup() - + // Allow Python paths on Windows for test environment (Python plugin scans for interpreters) if (SystemInfo.isWindows) { VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") } - + amazonQStreamingClient = AmazonQStreamingClient.getInstance(projectRule.project) ssoClient = mockClientManagerRule.create() From c172725101ce101e59d8bb1fec4b6d95fded2a6c Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 04:11:07 -0800 Subject: [PATCH 24/35] Retrigger CI - previous sdk-codegen failure was flaky From a11edbaf68d70f3580e96e1c17839ad9da5e397e Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 10:37:35 -0800 Subject: [PATCH 25/35] Fix Windows test - use @BeforeClass for VfsRootAccess before IDE startup Python plugin scans during IDE startup, before @Before runs. Use @BeforeClass with Disposer.newDisposable() to allow access earlier. --- .../amazonq/clients/AmazonQStreamingClientTest.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 6ddcc583c8c..ae2b4f78799 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 @@ -3,6 +3,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.clients +import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess import com.intellij.testFramework.RuleChain @@ -11,6 +12,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Before +import org.junit.BeforeClass import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any @@ -241,6 +243,14 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { } companion object { + @JvmStatic + @BeforeClass + fun allowWindowsPythonPaths() { + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "C:/Program Files") + } + } + private val VALIDATION_EXCEPTION = ValidationException.builder() .message("Resource validation failed") .build() From 420718a7c30f878c39a56c6e30e802872dfe4bee Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 11:18:49 -0800 Subject: [PATCH 26/35] Fix Windows test failure in TelemetryHelperTest - allow Python SDK paths Same issue as AmazonQStreamingClientTest - Python plugin scans for interpreters during IDE startup, attempting to access paths outside allowed test roots. Added @BeforeAll method (JUnit5 equivalent of @BeforeClass) to allow C:/Program Files access on Windows before IDE initialization. --- .../services/amazonq/TelemetryHelperTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt b/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt index 62d74f1ccf4..76a00df6560 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt @@ -4,6 +4,9 @@ package software.aws.toolkits.jetbrains.services.amazonq import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess import com.intellij.testFramework.fixtures.CodeInsightTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory import com.intellij.testFramework.fixtures.impl.LightTempDirTestFixtureImpl @@ -13,6 +16,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -102,6 +106,14 @@ class TelemetryHelperTest { private const val steRequestId = "sendTelemetryEventRequestId" private const val lang = "java" private val mockCustomization = CodeWhispererCustomization(customizationArn, "name", "description") + + @JvmStatic + @BeforeAll + fun allowWindowsPythonPaths() { + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "C:/Program Files") + } + } private val data = ChatRequestData( tabId = tabId, message = "foo", From 51ad4f9d4eb88ee6338ccfc809e350445b721d6f Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 11:22:07 -0800 Subject: [PATCH 27/35] Fix detek trailing spaces in AmazonQStreamingClientTest.kt --- .../services/amazonq/clients/AmazonQStreamingClientTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ae2b4f78799..fd93cc89c12 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 @@ -250,7 +250,7 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "C:/Program Files") } } - + private val VALIDATION_EXCEPTION = ValidationException.builder() .message("Resource validation failed") .build() From 4a864069aec83c94608a8df0de44899e4ef7bff3 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 12:11:04 -0800 Subject: [PATCH 28/35] Retrigger CI - previous sdk-codegen failure was flaky From 78358534f1e420a98919b8ab61ccc11265093f9d Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 18:16:49 -0800 Subject: [PATCH 29/35] Fix 2025.3 compatibility issues - Add com.jetbrains.codeWithMe plugin dependency to IdeVersions - Replace toUriString with relative path calculation in BrowserConnector - Extract virtualFile validation outside ReadAction in CodeWhisperer services - Move CwmProblemsViewMutator to jetbrains-ultimate with BackendToolWindowHost - Update CodeCatalyst Gateway customization to use new property-based API - Re-enable RebuildDevfileRequiredNotification with updated RD platform APIs Addresses PR #6098 review comments --- .../toolkits/gradle/intellij/IdeVersions.kt | 1 + .../amazonq/webview/BrowserConnector.kt | 14 ++++++-- .../service/CodeWhispererService.kt | 11 +++++-- .../service/CodeWhispererServiceNew.kt | 12 ++++--- .../jetbrains-ultimate/build.gradle.kts | 3 ++ .../jetbrains/CwmProblemsViewMutator.kt | 7 +++- .../CodeCatalystGatewayClientCustomizer.kt | 22 +++++++++---- .../RebuildDevfileRequiredNotification.kt | 32 ++++++++----------- 8 files changed, 66 insertions(+), 36 deletions(-) rename plugins/amazonq/shared/{jetbrains-community/src-253+ => jetbrains-ultimate/src}/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt (65%) 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 053f3d56f08..4b4548f8eb5 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 @@ -180,6 +180,7 @@ object IdeVersions { "JavaScript", "JavaScriptDebugger", "com.intellij.database", + "com.jetbrains.codeWithMe" ), marketplacePlugins = listOf( "Pythonid:253.28294.51", 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 65df0064c74..816cf9cd657 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 @@ -17,6 +17,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.jcef.JBCefJSQuery.Response import kotlinx.coroutines.CancellationException @@ -103,7 +104,6 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SendC import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.StopResponseMessage import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.TELEMETRY_EVENT import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString import software.aws.toolkits.jetbrains.services.amazonq.util.command import software.aws.toolkits.jetbrains.services.amazonq.util.tabType import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.AmazonQTheme @@ -232,7 +232,11 @@ class BrowserConnector( SEND_CHAT_COMMAND_PROMPT -> { val requestFromUi = serializer.deserializeChatMessages(node) val editor = FileEditorManager.getInstance(project).selectedTextEditor - val textDocumentIdentifier = editor?.virtualFile?.let { TextDocumentIdentifier(toUriString(it)) } + val textDocumentIdentifier = editor?.virtualFile?.let { virtualFile -> + val relativePath = VfsUtilCore.getRelativePath(virtualFile, project.baseDir) + ?: virtualFile.path + TextDocumentIdentifier(relativePath) + } val cursorState = editor?.let { LspEditorUtil.getCursorState(it) } val enrichmentParams = mapOf( @@ -362,7 +366,11 @@ class BrowserConnector( CHAT_INSERT_TO_CURSOR -> { val editor = FileEditorManager.getInstance(project).selectedTextEditor - val textDocumentIdentifier = editor?.virtualFile?.let { TextDocumentIdentifier(toUriString(it)) } + val textDocumentIdentifier = editor?.virtualFile?.let { virtualFile -> + val relativePath = VfsUtilCore.getRelativePath(virtualFile, project.baseDir) + ?: virtualFile.path + TextDocumentIdentifier(relativePath) + } val cursorPosition = editor?.let { LspEditorUtil.getCursorPosition(it) } val enrichmentParams = mapOf( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt index eb1db27ff72..b3398e170e8 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt @@ -512,8 +512,12 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { editor: Editor, triggerTypeInfo: TriggerTypeInfo, nextToken: Either?, - ): InlineCompletionWithReferencesParams = - ReadAction.compute { + ): InlineCompletionWithReferencesParams { + // Resolve and validate the virtualFile before entering the ReadAction + val virtualFile = editor.virtualFile + ?: error("Editor virtualFile is null for CodeWhisperer inline completion") + + return ReadAction.compute { InlineCompletionWithReferencesParams( context = InlineCompletionContext( // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind @@ -542,7 +546,7 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { .openFiles.mapNotNull { toUriString(it) } }.orEmpty(), ).apply { - textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile ?: return@compute null)) + textDocument = TextDocumentIdentifier(toUriString(virtualFile)) position = Position( editor.caretModel.primaryCaret.logicalPosition.line, editor.caretModel.primaryCaret.logicalPosition.column @@ -552,6 +556,7 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { } } } + } private fun addPopupChildDisposables(popup: JBPopup) { codeInsightSettingsFacade.disableCodeInsightUntil(popup) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt index 389227d4967..f8604b89686 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt @@ -558,8 +558,12 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { editor: Editor, triggerTypeInfo: TriggerTypeInfo, nextToken: Either?, - ): InlineCompletionWithReferencesParams = - ReadAction.compute { + ): InlineCompletionWithReferencesParams { + // Resolve and validate the virtualFile before entering the ReadAction + val virtualFile = editor.virtualFile + ?: error("Editor virtualFile is null for CodeWhisperer inline completion (new)") + + return ReadAction.compute { InlineCompletionWithReferencesParams( context = InlineCompletionContext( // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind @@ -572,7 +576,7 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { documentChangeParams = null, openTabFilepaths = null, ).apply { - textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile ?: return@compute null)) + textDocument = TextDocumentIdentifier(toUriString(virtualFile)) position = Position( editor.caretModel.primaryCaret.logicalPosition.line, editor.caretModel.primaryCaret.logicalPosition.column @@ -582,7 +586,7 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { } } } - + } private fun logServiceInvocation( requestContext: RequestContextNew, responseContext: ResponseContext, diff --git a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts index be7eae7ba3a..b4f9640cd76 100644 --- a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts @@ -12,6 +12,9 @@ intellijToolkit { } dependencies { + intellijPlatform { + bundledModule("intellij.rd.platform") + } compileOnly(project(":plugin-amazonq:shared:jetbrains-community")) compileOnly(project(":plugin-core:jetbrains-ultimate")) diff --git a/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt similarity index 65% rename from plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt rename to plugins/amazonq/shared/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt index 3c0026cbab8..3346352e7ed 100644 --- a/plugins/amazonq/shared/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt +++ b/plugins/amazonq/shared/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt @@ -6,9 +6,14 @@ package software.aws.toolkits.jetbrains import com.intellij.analysis.problemsView.toolWindow.ProblemsView import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow +import com.jetbrains.rdserver.toolWindow.BackendToolWindowHost class CwmProblemsViewMutator : ProblemsViewMutator { override fun mutateProblemsView(project: Project, runnable: (ToolWindow) -> Unit) { - ProblemsView.getToolWindow(project)?.let { runnable(it) } + BackendToolWindowHost.getAllInstances(project).forEach { host -> + host.getToolWindow(ProblemsView.ID)?.let { + runnable(it) + } + } } } diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt index 4956b334ed1..60f22f29b59 100644 --- a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt @@ -3,10 +3,12 @@ package software.aws.toolkits.jetbrains.remoteDev.caws -// TODO: GatewayClientCustomizationProvider removed in 2025.3 - investigate new Gateway customization APIs -/* import com.intellij.openapi.extensions.ExtensionNotApplicableException -import com.jetbrains.rdserver.unattendedHost.customization.controlCenter.GatewayClientCustomizationProvider +import com.jetbrains.rdserver.unattendedHost.customization.DefaultGatewayExitCustomizationProvider +import com.jetbrains.rdserver.unattendedHost.customization.GatewayClientCustomizationProvider +import com.jetbrains.rdserver.unattendedHost.customization.GatewayExitCustomizationProvider +import com.jetbrains.rdserver.unattendedHost.customization.controlCenter.GatewayControlCenterProvider +import com.jetbrains.rdserver.unattendedHost.customization.controlCenter.GatewayHostnameDisplayKind import icons.AwsIcons import software.aws.toolkits.jetbrains.utils.isCodeCatalystDevEnv import software.aws.toolkits.resources.message @@ -18,8 +20,16 @@ class CodeCatalystGatewayClientCustomizer : GatewayClientCustomizationProvider { } } - override fun getIcon() = AwsIcons.Logos.AWS_SMILE_SMALL + override val controlCenter: GatewayControlCenterProvider = object : GatewayControlCenterProvider { + override fun getHostnameDisplayKind() = GatewayHostnameDisplayKind.ShowHostnameOnNavbar + override fun getHostnameLong() = title + override fun getHostnameShort() = title + } + + override val icon = AwsIcons.Logos.CODE_CATALYST_SMALL + override val title = message("caws.workspace.backend.title") - override fun getTitle() = message("caws.gateway.title") + override val exitCustomization: GatewayExitCustomizationProvider = object : GatewayExitCustomizationProvider by DefaultGatewayExitCustomizationProvider() { + override val isEnabled: Boolean = false + } } -*/ diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt index 5ec08592773..da61ccc13b7 100644 --- a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt @@ -1,33 +1,27 @@ -// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.remoteDev.caws -// TODO: Re-enable when RD platform APIs are available in 2025.3 -// The com.jetbrains.rd.platform.codeWithMe APIs are not available in 2025.3 EAP -// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.Metric -// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricType -// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricsStatus -// import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.providers.MetricProvider +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.Metric +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricType +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricsStatus +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.providers.MetricProvider +import software.aws.toolkits.resources.message -/* class RebuildDevfileRequiredNotification : MetricProvider { override val id: String get() = "devfileRebuildRequired" - override fun getMetrics(): List = listOf( - object : Metric { - override val id: String - get() = "devfileRebuildRequired" - override val type: MetricType - get() = MetricType.PERFORMANCE - override val status: MetricsStatus - get() = MetricsStatus.RED + override fun getMetrics(): Map = + if (DevfileWatcher.getInstance().hasDevfileChanged()) { + mapOf(Pair("devfileRebuild", RebuildDevfileMetric)) + } else { + mapOf() } - ) - inner class DevfileRebuildRequiredMetric : Metric { + // Adding MetricStatus as Danger instead of Warning, cause Warning is overriden by other notifications provided by the client + object RebuildDevfileMetric : Metric(MetricType.OTHER, MetricsStatus.DANGER, true) { override fun toString(): String = message("caws.rebuild.workspace.notification") } } -*/ From 7928093e83d71ced988aedf158c9e8f4bd97fb3c Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 21:58:30 -0800 Subject: [PATCH 30/35] Fix rd.platform dependency for IDE versions < 2025.2 The intellij.rd.platform bundled module only exists in IDE versions 2025.2 and later. This change makes the dependency conditional to prevent build failures on earlier IDE versions (2024.3, 2025.1). --- plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts index b4f9640cd76..5d1f4852318 100644 --- a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts @@ -13,7 +13,12 @@ intellijToolkit { dependencies { intellijPlatform { - bundledModule("intellij.rd.platform") + // RD platform is only available in 2025.3 and later + when (providers.gradleProperty("ideProfileName").get()) { + "2025.2","2025.3" -> { + bundledModule("intellij.rd.platform") + } + } } compileOnly(project(":plugin-amazonq:shared:jetbrains-community")) compileOnly(project(":plugin-core:jetbrains-ultimate")) From 979ac44ab9bb682ac024abe7a761c566e780eacc Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 22:21:29 -0800 Subject: [PATCH 31/35] Fix detekt spacing issue in build.gradle.kts Add space after comma in version string list to comply with detekt code style requirements. --- plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts index 5d1f4852318..ee3df06cae0 100644 --- a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { intellijPlatform { // RD platform is only available in 2025.3 and later when (providers.gradleProperty("ideProfileName").get()) { - "2025.2","2025.3" -> { + "2025.2", "2025.3" -> { bundledModule("intellij.rd.platform") } } From 39ea5b066281cc585dbcad848c27f62b95d91a99 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 22:37:21 -0800 Subject: [PATCH 32/35] Retrigger CI - investigate detekt/coverage failure From 7dabfeefb417a7ce0391096d409bedaeb06366b4 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Thu, 13 Nov 2025 23:03:22 -0800 Subject: [PATCH 33/35] Remove duplicate CwmProblemsViewMutator from jetbrains-community This file was already moved to jetbrains-ultimate but the old version in src-242-252 was not deleted, causing duplicate class errors in the coverage report for IDE versions 2024.2-2025.2. --- .../jetbrains/CwmProblemsViewMutator.kt | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt diff --git a/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt deleted file mode 100644 index 3346352e7ed..00000000000 --- a/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains - -import com.intellij.analysis.problemsView.toolWindow.ProblemsView -import com.intellij.openapi.project.Project -import com.intellij.openapi.wm.ToolWindow -import com.jetbrains.rdserver.toolWindow.BackendToolWindowHost - -class CwmProblemsViewMutator : ProblemsViewMutator { - override fun mutateProblemsView(project: Project, runnable: (ToolWindow) -> Unit) { - BackendToolWindowHost.getAllInstances(project).forEach { host -> - host.getToolWindow(ProblemsView.ID)?.let { - runnable(it) - } - } - } -} From 5801765389beeb559168db5c4efc4bb0d953a6d4 Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Fri, 14 Nov 2025 00:00:14 -0800 Subject: [PATCH 34/35] Restore CwmProblemsViewMutator in jetbrains-community for IDE versions 2024.2-2025.2 --- .../jetbrains/CwmProblemsViewMutator.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt diff --git a/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt new file mode 100644 index 00000000000..3346352e7ed --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt @@ -0,0 +1,19 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains + +import com.intellij.analysis.problemsView.toolWindow.ProblemsView +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.jetbrains.rdserver.toolWindow.BackendToolWindowHost + +class CwmProblemsViewMutator : ProblemsViewMutator { + override fun mutateProblemsView(project: Project, runnable: (ToolWindow) -> Unit) { + BackendToolWindowHost.getAllInstances(project).forEach { host -> + host.getToolWindow(ProblemsView.ID)?.let { + runnable(it) + } + } + } +} From df397610329fe6f0399791e124f86246821f480a Mon Sep 17 00:00:00 2001 From: Aseem Sharma Date: Fri, 14 Nov 2025 00:42:38 -0800 Subject: [PATCH 35/35] Move CwmProblemsViewMutator to src-253+ to prevent duplicate class in JaCoCo coverage for IDE versions 2024.2-2025.2 --- .../software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/amazonq/shared/jetbrains-ultimate/{src => src-253+}/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt (100%) diff --git a/plugins/amazonq/shared/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt rename to plugins/amazonq/shared/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt