diff --git a/.changes/3.63.json b/.changes/3.63.json new file mode 100644 index 00000000000..806e763d55a --- /dev/null +++ b/.changes/3.63.json @@ -0,0 +1,11 @@ +{ + "date" : "2025-04-08", + "version" : "3.63", + "entries" : [ { + "type" : "feature", + "description" : "Enterprise users can choose their preferred Amazon Q profile to improve personalization and workflow across different business regions" + }, { + "type" : "bugfix", + "description" : "Amazon Q /doc: close diff tab and open README file in preview mode after user accept changes" + } ] +} \ No newline at end of file diff --git a/.changes/3.64.json b/.changes/3.64.json new file mode 100644 index 00000000000..942136a9fd8 --- /dev/null +++ b/.changes/3.64.json @@ -0,0 +1,8 @@ +{ + "date" : "2025-04-10", + "version" : "3.64", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix issue where IDE freezes when logging into Amazon Q" + } ] +} \ No newline at end of file diff --git a/.changes/3.65.json b/.changes/3.65.json new file mode 100644 index 00000000000..0c50976e354 --- /dev/null +++ b/.changes/3.65.json @@ -0,0 +1,8 @@ +{ + "date" : "2025-04-10", + "version" : "3.65", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix issue where Amazon Q cannot process chunks from local `@workspace` context" + } ] +} \ No newline at end of file diff --git a/.changes/3.66.json b/.changes/3.66.json new file mode 100644 index 00000000000..602551b5fd1 --- /dev/null +++ b/.changes/3.66.json @@ -0,0 +1,11 @@ +{ + "date" : "2025-04-11", + "version" : "3.66", + "entries" : [ { + "type" : "feature", + "description" : "The logs emitted by the Agent during user command execution will be accepted and written to `.amazonq/dev/run_command.log` file in the user's local repository." + }, { + "type" : "bugfix", + "description" : "Unit test generation now completes successfully when using the `/test` command " + } ] +} \ No newline at end of file diff --git a/.changes/3.67.json b/.changes/3.67.json new file mode 100644 index 00000000000..ab39cadb25c --- /dev/null +++ b/.changes/3.67.json @@ -0,0 +1,20 @@ +{ + "date" : "2025-04-18", + "version" : "3.67", + "entries" : [ { + "type" : "bugfix", + "description" : "Amazon Q: Customization now resets with a warning if unavailable in the selected profile." + }, { + "type" : "bugfix", + "description" : "Q panel will get stuck while signin if users have multiple windows" + }, { + "type" : "bugfix", + "description" : "Fix integer overflow when local context index input is larger than 2GB" + }, { + "type" : "bugfix", + "description" : "Fix workspace index process quits when hitting a race condition" + }, { + "type" : "bugfix", + "description" : "Fix infinite loop when workspace indexing server fails to initialize" + } ] +} \ No newline at end of file diff --git a/.changes/3.68.json b/.changes/3.68.json new file mode 100644 index 00000000000..59672f4e793 --- /dev/null +++ b/.changes/3.68.json @@ -0,0 +1,20 @@ +{ + "date" : "2025-04-23", + "version" : "3.68", + "entries" : [ { + "type" : "feature", + "description" : "Amazon Q: Show visual indicator in status bar if profile selection is needed to continue with Q Inline / Q Chat" + }, { + "type" : "feature", + "description" : "Amazon Q /test: Remove unsupported message for non-java python languages" + }, { + "type" : "bugfix", + "description" : "/dev: Fix prompt to enable devfile build not triggering when devfile is present." + }, { + "type" : "bugfix", + "description" : "/review disable auto scan by default" + }, { + "type" : "bugfix", + "description" : "/review: disabled highlighter for ignored issues" + } ] +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-19118cf8-9378-4bd6-bf5e-7e57520181d0.json b/.changes/next-release/bugfix-19118cf8-9378-4bd6-bf5e-7e57520181d0.json deleted file mode 100644 index 4121214a68a..00000000000 --- a/.changes/next-release/bugfix-19118cf8-9378-4bd6-bf5e-7e57520181d0.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "bugfix", - "description" : "Amazon Q /doc: close diff tab and open README file in preview mode after user accept changes" -} \ No newline at end of file diff --git a/.changes/next-release/removal-5979b9e4-898d-405a-9fc4-5919bb972a0e.json b/.changes/next-release/removal-5979b9e4-898d-405a-9fc4-5919bb972a0e.json new file mode 100644 index 00000000000..0a712912815 --- /dev/null +++ b/.changes/next-release/removal-5979b9e4-898d-405a-9fc4-5919bb972a0e.json @@ -0,0 +1,4 @@ +{ + "type" : "removal", + "description" : "Removed support for 2024.1.x IDEs" +} \ No newline at end of file diff --git a/.changes/next-release/removal-6b54ddb0-1385-4788-a225-ddae6ac958d2.json b/.changes/next-release/removal-6b54ddb0-1385-4788-a225-ddae6ac958d2.json new file mode 100644 index 00000000000..a9c2061c505 --- /dev/null +++ b/.changes/next-release/removal-6b54ddb0-1385-4788-a225-ddae6ac958d2.json @@ -0,0 +1,4 @@ +{ + "type" : "removal", + "description" : "Removed support for Gateway 2024.3" +} \ No newline at end of file diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 7747ca912ff..8b5d44c0f46 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: build_target: [ ':plugin-core:buildPlugin', ':plugin-toolkit:intellij-standalone:buildPlugin', ':plugin-amazonq:buildPlugin' ] - version: [ '2024.1', '2024.2', '2024.3', '2025.1' ] + version: [ '2024.2', '2024.3', '2025.1' ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.run/Run AWS Toolkit - Community [2024.1].run.xml b/.run/Run AWS Toolkit - Community [2024.1].run.xml deleted file mode 100644 index 3d66c9885ff..00000000000 --- a/.run/Run AWS Toolkit - Community [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Gateway [2024.3].run.xml b/.run/Run AWS Toolkit - Gateway [2024.3].run.xml deleted file mode 100644 index f59c70407d5..00000000000 --- a/.run/Run AWS Toolkit - Gateway [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Rider [2024.1].run.xml b/.run/Run AWS Toolkit - Rider [2024.1].run.xml deleted file mode 100644 index 6748da64f44..00000000000 --- a/.run/Run AWS Toolkit - Rider [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Ultimate [2024.1].run.xml b/.run/Run AWS Toolkit - Ultimate [2024.1].run.xml deleted file mode 100644 index b1f9246c803..00000000000 --- a/.run/Run AWS Toolkit - Ultimate [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Community [2024.1].run.xml b/.run/Run All - Community [2024.1].run.xml deleted file mode 100644 index e1760d16510..00000000000 --- a/.run/Run All - Community [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Rider [2024.1].run.xml b/.run/Run All - Rider [2024.1].run.xml deleted file mode 100644 index 2fe2c1b538f..00000000000 --- a/.run/Run All - Rider [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Ultimate [2024.1].run.xml b/.run/Run All - Ultimate [2024.1].run.xml deleted file mode 100644 index 606517ed2ed..00000000000 --- a/.run/Run All - Ultimate [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Community [2024.1].run.xml b/.run/Run Amazon Q - Community [2024.1].run.xml deleted file mode 100644 index 48037481044..00000000000 --- a/.run/Run Amazon Q - Community [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Rider [2024.1].run.xml b/.run/Run Amazon Q - Rider [2024.1].run.xml deleted file mode 100644 index a66d2fb3045..00000000000 --- a/.run/Run Amazon Q - Rider [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Ultimate [2024.1].run.xml b/.run/Run Amazon Q - Ultimate [2024.1].run.xml deleted file mode 100644 index c16f5123324..00000000000 --- a/.run/Run Amazon Q - Ultimate [2024.1].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/generateConfigs.py b/.run/generateConfigs.py index 7a939d627c8..2041a6a85e9 100644 --- a/.run/generateConfigs.py +++ b/.run/generateConfigs.py @@ -52,7 +52,7 @@ def write_config(mv: str, ide: IdeVariant, plugin: PluginVariant): f.write(TEMPLATE.format(plugin = plugin, variant = ide, major_version = mv)) if __name__ == '__main__': - mvs = ["2024.1", "2024.2", "2024.3", "2025.1"] + mvs = ["2024.2", "2024.3", "2025.1"] ides = [ IdeVariant("Community", "IC"), IdeVariant("Rider", "RD"), diff --git a/CHANGELOG.md b/CHANGELOG.md index 321e017b9fe..2e673ce7a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +# _3.68_ (2025-04-23) +- **(Feature)** Amazon Q: Show visual indicator in status bar if profile selection is needed to continue with Q Inline / Q Chat +- **(Feature)** Amazon Q /test: Remove unsupported message for non-java python languages +- **(Bug Fix)** /dev: Fix prompt to enable devfile build not triggering when devfile is present. +- **(Bug Fix)** /review disable auto scan by default +- **(Bug Fix)** /review: disabled highlighter for ignored issues + +# _3.67_ (2025-04-18) +- **(Bug Fix)** Amazon Q: Customization now resets with a warning if unavailable in the selected profile. +- **(Bug Fix)** Q panel will get stuck while signin if users have multiple windows +- **(Bug Fix)** Fix integer overflow when local context index input is larger than 2GB +- **(Bug Fix)** Fix workspace index process quits when hitting a race condition +- **(Bug Fix)** Fix infinite loop when workspace indexing server fails to initialize + +# _3.66_ (2025-04-11) +- **(Feature)** The logs emitted by the Agent during user command execution will be accepted and written to `.amazonq/dev/run_command.log` file in the user's local repository. +- **(Bug Fix)** Unit test generation now completes successfully when using the `/test` command + +# _3.64_ (2025-04-10) +- **(Bug Fix)** Fix issue where IDE freezes when logging into Amazon Q + +# _3.65_ (2025-04-10) +- **(Bug Fix)** Fix issue where Amazon Q cannot process chunks from local `@workspace` context + +# _3.63_ (2025-04-08) +- **(Feature)** Enterprise users can choose their preferred Amazon Q profile to improve personalization and workflow across different business regions +- **(Bug Fix)** Amazon Q /doc: close diff tab and open README file in preview mode after user accept changes + # _3.62_ (2025-04-03) - **(Feature)** /review: automatically generate fix without clicking Generate Fix button - **(Bug Fix)** /transform: prompt user to re-authenticate if credentials expire during transformation 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 84c520c9b12..b5b02fc20b8 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt @@ -22,16 +22,13 @@ fun Project.ciOnly(block: () -> Unit) { fun Project.isCi() : Boolean = providers.environmentVariable("CI").isPresent fun Project.jvmTarget(): Provider = withCurrentProfileName { - when (it) { - "2023.3", "2024.1" -> JavaVersion.VERSION_17 - else -> JavaVersion.VERSION_21 - } + JavaVersion.VERSION_21 } // https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#stdlib-miscellaneous fun Project.kotlinTarget(): Provider = withCurrentProfileName { when (it) { - "2023.3", "2024.1", "2024.2" -> KotlinVersionEnum.KOTLIN_1_9 + "2024.2" -> KotlinVersionEnum.KOTLIN_1_9 "2024.3" -> KotlinVersionEnum.KOTLIN_2_0 "2025.1" -> KotlinVersionEnum.KOTLIN_2_1 else -> error("not set") 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 9a9107d503b..db4062b9f68 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt @@ -26,78 +26,6 @@ object IdeVersions { ) private val ideProfiles = listOf( - Profile( - name = "2023.3", - community = ProductProfile( - sdkVersion = "2023.3", - bundledPlugins = commonPlugins + listOf( - "com.intellij.java", - "com.intellij.gradle", - "org.jetbrains.idea.maven", - ), - marketplacePlugins = listOf( - "PythonCore:233.11799.241", - "Docker:233.11799.244" - ) - ), - ultimate = ProductProfile( - sdkVersion = "2023.3", - bundledPlugins = commonPlugins + listOf( - "JavaScript", - "JavaScriptDebugger", - "com.intellij.database", - "com.jetbrains.codeWithMe", - ), - marketplacePlugins = listOf( - "Pythonid:233.11799.241", - "org.jetbrains.plugins.go:233.11799.196", - ) - ), - rider = RiderProfile( - sdkVersion = "2023.3", - bundledPlugins = commonPlugins, - netFrameworkTarget = "net472", - rdGenVersion = "2023.3.2", - nugetVersion = "2023.3.0" - ) - ), - Profile( - name = "2024.1", - community = ProductProfile( - sdkVersion = "2024.1", - bundledPlugins = commonPlugins + listOf( - "com.intellij.java", - "com.intellij.gradle", - "org.jetbrains.idea.maven", - ), - marketplacePlugins = listOf( - "org.toml.lang:241.14494.150", - "PythonCore:241.14494.240", - "Docker:241.14494.251" - ) - ), - ultimate = ProductProfile( - sdkVersion = "2024.1", - bundledPlugins = commonPlugins + listOf( - "JavaScript", - "JavaScriptDebugger", - "com.intellij.database", - "com.jetbrains.codeWithMe", - ), - marketplacePlugins = listOf( - "org.toml.lang:241.14494.150", - "Pythonid:241.14494.314", - "org.jetbrains.plugins.go:241.14494.240", - ) - ), - rider = RiderProfile( - sdkVersion = "2024.1", - bundledPlugins = commonPlugins, - netFrameworkTarget = "net472", - rdGenVersion = "2024.1.1", - nugetVersion = "2024.1.0" - ) - ), Profile( name = "2024.2", community = ProductProfile( @@ -137,10 +65,6 @@ object IdeVersions { ), Profile( name = "2024.3", - gateway = ProductProfile( - sdkVersion = "243.21565.196-CUSTOM-SNAPSHOT", - bundledPlugins = listOf("org.jetbrains.plugins.terminal") - ), community = ProductProfile( sdkVersion = "2024.3", bundledPlugins = commonPlugins + listOf( diff --git a/gradle.properties b/gradle.properties index 5921806d5e7..aa67fe52d2d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.63-SNAPSHOT +toolkitVersion=3.69-SNAPSHOT # Publish Settings publishToken= diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e017dce6fed..1d5acb6e703 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,6 +106,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = kotlin-stdLibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } mockk = { module = "io.mockk:mockk", version.ref="mockk" } nimbus-jose-jwt = {module = "com.nimbusds:nimbus-jose-jwt", version.ref = "nimbus-jose-jwt"} @@ -121,7 +122,7 @@ zjsonpatch = { module = "com.flipkart.zjsonpatch:zjsonpatch", version.ref = "zjs [bundles] jackson = ["jackson-datetime", "jackson-kotlin", "jackson-yaml", "jackson-xml"] kotlin = ["kotlin-stdLibJdk8", "kotlin-reflect"] -mockito = ["mockito-core", "mockito-kotlin"] +mockito = ["mockito-core", "mockito-junit-jupiter", "mockito-kotlin"] sshd = ["sshd-core", "sshd-scp", "sshd-sftp"] [plugins] diff --git a/kotlinResolution.settings.gradle.kts b/kotlinResolution.settings.gradle.kts index cf5ce3351f2..d78853740bc 100644 --- a/kotlinResolution.settings.gradle.kts +++ b/kotlinResolution.settings.gradle.kts @@ -6,10 +6,6 @@ dependencyResolutionManagement { maybeCreate("libs").apply { // pull value from IJ library list: https://github.com/JetBrains/intellij-community/blob//.idea/libraries/kotlinx_coroutines_core.xml val version = when (providers.gradleProperty("ideProfileName").getOrNull() ?: return@apply) { - "2023.3", "2024.1" -> { - "1.7.3" - } - "2024.2" -> { "1.8.0-intellij-9" } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/clients/AmazonQCodeGenerateClient.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/clients/AmazonQCodeGenerateClient.kt index 2c28c3240f3..3957d9fdf46 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/clients/AmazonQCodeGenerateClient.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/clients/AmazonQCodeGenerateClient.kt @@ -31,10 +31,10 @@ 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.common.session.Intent -import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.services.amazonq.clients.AmazonQStreamingClient +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonqDoc.FEATURE_EVALUATION_PRODUCT_NAME import software.aws.toolkits.jetbrains.services.codemodernizer.utils.calculateTotalLatency import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata @@ -72,7 +72,7 @@ class AmazonQCodeGenerateClient(private val project: Project) { fun connection() = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) ?: error("Attempted to use connection while one does not exist") - fun bearerClient() = connection().getConnectionSettings().awsClient() + fun bearerClient() = QRegionProfileManager.getInstance().getQClient(project) private val amazonQStreamingClient get() = AmazonQStreamingClient.getInstance(project) @@ -88,6 +88,7 @@ class AmazonQCodeGenerateClient(private val project: Project) { } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(docUserContext) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun sendDocMetricData(operationName: String, result: String): SendTelemetryEventResponse = @@ -118,7 +119,9 @@ class AmazonQCodeGenerateClient(private val project: Project) { } fun createTaskAssistConversation(): CreateTaskAssistConversationResponse = bearerClient().createTaskAssistConversation( - CreateTaskAssistConversationRequest.builder().build() + CreateTaskAssistConversationRequest.builder() + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) + .build() ) fun createTaskAssistUploadUrl(conversationId: String, contentChecksumSha256: String, contentLength: Long): CreateUploadUrlResponse = @@ -137,6 +140,7 @@ class AmazonQCodeGenerateClient(private val project: Project) { ) .build() ) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun startTaskAssistCodeGeneration(conversationId: String, uploadId: String, userMessage: String, intent: Intent): StartTaskAssistCodeGenerationResponse = @@ -155,6 +159,7 @@ class AmazonQCodeGenerateClient(private val project: Project) { .uploadId(uploadId) } .intent(intent.name) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun getTaskAssistCodeGeneration(conversationId: String, codeGenerationId: String): GetTaskAssistCodeGenerationResponse = bearerClient() diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QLoginWebview.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QLoginWebview.kt index 67ceff60677..1d614bef9bf 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QLoginWebview.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QLoginWebview.kt @@ -3,9 +3,11 @@ package software.aws.toolkits.jetbrains.services.amazonq +import com.intellij.ide.BrowserUtil import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.runInEdt import com.intellij.openapi.components.Service import com.intellij.openapi.components.service @@ -16,6 +18,7 @@ import com.intellij.ui.components.panels.Wrapper import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.panel import com.intellij.ui.jcef.JBCefJSQuery +import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn @@ -32,14 +35,20 @@ import software.aws.toolkits.jetbrains.core.webview.BrowserState import software.aws.toolkits.jetbrains.core.webview.LocalAssetJBCefRequestHandler import software.aws.toolkits.jetbrains.core.webview.LoginBrowser import software.aws.toolkits.jetbrains.isDeveloperMode +import software.aws.toolkits.jetbrains.services.amazonq.profile.QProfileSwitchIntent +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonq.util.createBrowser import software.aws.toolkits.jetbrains.utils.isQConnected import software.aws.toolkits.jetbrains.utils.isQExpired import software.aws.toolkits.jetbrains.utils.isQWebviewsAvailable import software.aws.toolkits.telemetry.FeatureId +import software.aws.toolkits.telemetry.MetricResult +import software.aws.toolkits.telemetry.Telemetry import software.aws.toolkits.telemetry.UiTelemetry import software.aws.toolkits.telemetry.WebviewTelemetry import java.awt.event.ActionListener +import java.net.URI import javax.swing.JButton import javax.swing.JComponent @@ -192,6 +201,26 @@ class QWebviewBrowser(val project: Project, private val parentDisposable: Dispos UiTelemetry.click(project, signInOption) } } + + is BrowserMessage.SwitchProfile -> { + QRegionProfileManager.getInstance().switchProfile( + project, + QRegionProfile(profileName = message.profileName, arn = message.arn), + intent = QProfileSwitchIntent.Auth + ) + } + + is BrowserMessage.ListProfiles -> { + handleListProfilesMessage() + } + + is BrowserMessage.PublishWebviewTelemetry -> { +// publishTelemetry(message) + } + + is BrowserMessage.OpenUrl -> { + BrowserUtil.browse(URI(message.externalLink)) + } } } @@ -231,28 +260,44 @@ class QWebviewBrowser(val project: Project, private val parentDisposable: Dispos writeValueAsString(it) } - // TODO: pass "REAUTH" if connection expires val stage = if (isQExpired(project)) { "REAUTH" + } else if (isQConnected(project) && QRegionProfileManager.getInstance().isPendingProfileSelection(project)) { + "PROFILE_SELECT" } else { "START" } - val jsonData = """ - { - stage: '$stage', - regions: $regions, - idcInfo: { - profileName: '${lastLoginIdcInfo.profileName}', - startUrl: '${lastLoginIdcInfo.startUrl}', - region: '${lastLoginIdcInfo.region}' - }, - cancellable: ${state.browserCancellable}, - feature: '${state.feature}', - existConnections: ${writeValueAsString(selectionSettings.values.map { it.currentSelection }.toList())} + when (stage) { + "PROFILE_SELECT" -> { + val jsonData = """ + { + stage: '$stage', + status: 'pending' + } + """.trimIndent() + executeJS("window.ideClient.prepareUi($jsonData)") + } + + else -> { + val jsonData = """ + { + stage: '$stage', + regions: $regions, + idcInfo: { + profileName: '${lastLoginIdcInfo.profileName}', + startUrl: '${lastLoginIdcInfo.startUrl}', + region: '${lastLoginIdcInfo.region}' + }, + cancellable: ${state.browserCancellable}, + feature: '${state.feature}', + existConnections: ${writeValueAsString(selectionSettings.values.map { it.currentSelection }.toList())}, + } + """.trimIndent() + + executeJS("window.ideClient.prepareUi($jsonData)") } - """.trimIndent() - executeJS("window.ideClient.prepareUi($jsonData)") + } } override fun loginIAM(profileName: String, accessKey: String, secretKey: String) { @@ -269,6 +314,52 @@ class QWebviewBrowser(val project: Project, private val parentDisposable: Dispos jcefBrowser.loadURL(assetHandler.createResource("content.html", getWebviewHTML(webScriptUri, query))) } + private fun handleListProfilesMessage() { + ApplicationManager.getApplication().executeOnPooledThread { + var errorMessage = "" + val profiles = try { + QRegionProfileManager.getInstance().listRegionProfiles(project) + } catch (e: Exception) { + e.message?.let { + errorMessage = it + } + LOG.warn { "Failed to call listRegionProfiles API: $errorMessage" } + val qConn = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + Telemetry.amazonq.didSelectProfile.use { span -> + span.source(QProfileSwitchIntent.Auth.value) + .amazonQProfileRegion(QRegionProfileManager.getInstance().activeProfile(project)?.region ?: "not-set") + .ssoRegion((qConn as? AwsBearerTokenConnection)?.region) + .credentialStartUrl((qConn as? AwsBearerTokenConnection)?.startUrl) + .result(MetricResult.Failed) + .reason(e.message) + } + + null + } + + // auto-select the profile if users only have 1 and don't show the UI + if (profiles?.size == 1) { + LOG.debug { "User only have access to 1 Q profile, auto-selecting profile ${profiles.first().profileName} for ${project.name}" } + QRegionProfileManager.getInstance().switchProfile(project, profiles.first(), QProfileSwitchIntent.Update) + return@executeOnPooledThread + } + + // required EDT as this entire block is executed on thread pool + runInEdt { + val jsonData = """ + { + stage: 'PROFILE_SELECT', + status: '${if (profiles != null) "succeeded" else "failed"}', + profiles: ${writeValueAsString(profiles ?: "")}, + errorMessage: '$errorMessage' + } + """.trimIndent() + + executeJS("window.ideClient.prepareUi($jsonData)") + } + } + } + companion object { private val LOG = getLogger() private const val WEB_SCRIPT = "js/getStart.js" diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt index f114ae98239..b85d94db10f 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt @@ -19,6 +19,8 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.core.gettingstarted.emitUserState import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonq.project.ProjectContextController import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindow import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory @@ -52,6 +54,10 @@ class AmazonQStartupActivity : ProjectActivity { CodeWhispererExplorerActionManager.getInstance().setIsFirstRestartAfterQInstall(false) } } + + QRegionProfileManager.getInstance().validateProfile(project) + + AmazonQLspService.getInstance(project) startLsp(project) if (runOnce.get()) return emitUserState(project) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt index 15a533d1191..4c1191cd85d 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt @@ -24,6 +24,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage import software.aws.toolkits.jetbrains.services.amazonq.messages.MessageConnector import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteractionType +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonq.util.highlightCommand import software.aws.toolkits.jetbrains.services.amazonq.webview.BrowserConnector import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter @@ -66,7 +67,9 @@ class AmazonQToolWindow private constructor( connectUi() connectApps() - ApplicationManager.getApplication().messageBus.syncPublisher(LafManagerListener.TOPIC).lookAndFeelChanged(LafManager.getInstance()) + runInEdt { + ApplicationManager.getApplication().messageBus.syncPublisher(LafManagerListener.TOPIC).lookAndFeelChanged(LafManager.getInstance()) + } } private fun sendMessage(message: AmazonQMessage, tabType: String) { @@ -127,7 +130,8 @@ class AmazonQToolWindow private constructor( isCodeScanAvailable = isCodeScanAvailable(project), isCodeTestAvailable = isCodeTestAvailable(project), isDocAvailable = isDocAvailable(project), - highlightCommand = highlightCommand() + highlightCommand = highlightCommand(), + activeProfile = QRegionProfileManager.getInstance().takeIf { it.shouldDisplayProfileInfo(project) }?.activeProfile(project) ) scope.launch { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt index 3435075caaf..f9279eb61be 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt @@ -14,12 +14,10 @@ import com.intellij.ui.components.panels.Wrapper import com.intellij.util.ui.components.BorderLayoutPanel import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection -import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES 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.notifications.NotificationPanel @@ -28,6 +26,9 @@ import software.aws.toolkits.jetbrains.core.webview.BrowserState import software.aws.toolkits.jetbrains.services.amazonq.QWebviewPanel import software.aws.toolkits.jetbrains.services.amazonq.RefreshQChatPanelButtonPressedListener import software.aws.toolkits.jetbrains.services.amazonq.gettingstarted.openMeetQPage +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.utils.isQConnected import software.aws.toolkits.jetbrains.utils.isQExpired import software.aws.toolkits.jetbrains.utils.isQWebviewsAvailable @@ -62,7 +63,10 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { ToolkitConnectionManagerListener.TOPIC, object : ToolkitConnectionManagerListener { override fun activeConnectionChanged(newConnection: ToolkitConnection?) { - onConnectionChanged(project, newConnection, qPanel) + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance())?.let { qConn -> + openMeetQPage(project) + } + prepareChatContent(project, qPanel) } } ) @@ -71,9 +75,7 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { RefreshQChatPanelButtonPressedListener.TOPIC, object : RefreshQChatPanelButtonPressedListener { override fun onRefresh() { - runInEdt { - prepareChatContent(project, qPanel) - } + prepareChatContent(project, qPanel) } } ) @@ -83,16 +85,25 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { object : BearerTokenProviderListener { override fun onChange(providerId: String, newScopes: List?) { if (ToolkitConnectionManager.getInstance(project).connectionStateForFeature(QConnection.getInstance()) == BearerTokenAuthState.AUTHORIZED) { - val qComponent = AmazonQToolWindow.getInstance(project).component - - runInEdt { - qPanel.setContent(qComponent) - } + AmazonQToolWindow.getInstance(project).disposeAndRecreate() + prepareChatContent(project, qPanel) } } } ) + project.messageBus.connect(toolWindow.disposable).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + // note we name myProject intentionally ow it will shadow the "project" provided by the IDE + override fun onProfileSelected(myProject: Project, profile: QRegionProfile?) { + if (project.isDisposed) return + AmazonQToolWindow.getInstance(project).disposeAndRecreate() + qPanel.setContent(AmazonQToolWindow.getInstance(project).component) + } + } + ) + prepareChatContent(project, qPanel) val content = contentManager.factory.createContent(mainPanel, null, false).also { @@ -107,13 +118,21 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { project: Project, qPanel: Wrapper, ) { - val component = if (isQConnected(project) && !isQExpired(project)) { + /** + * only render Q Chat when + * 1. There is a Q connection + * 2. Q connection is not expired + * 3. User is not pending region profile selection + */ + val component = if (isQConnected(project) && !isQExpired(project) && !QRegionProfileManager.getInstance().isPendingProfileSelection(project)) { AmazonQToolWindow.getInstance(project).component } else { QWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AmazonQ)) QWebviewPanel.getInstance(project).component } - qPanel.setContent(component) + runInEdt { + qPanel.setContent(component) + } } override fun init(toolWindow: ToolWindow) { @@ -134,36 +153,6 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { override fun shouldBeAvailable(project: Project): Boolean = isQWebviewsAvailable() - private fun onConnectionChanged(project: Project, newConnection: ToolkitConnection?, qPanel: Wrapper) { - val isNewConnectionForQ = newConnection?.let { - (it as? AwsBearerTokenConnection)?.let { conn -> - val scopeShouldHave = Q_SCOPES - - LOG.debug { "newConnection: ${conn.id}; scope: ${conn.scopes}; scope must-have: $scopeShouldHave" } - - scopeShouldHave.all { s -> s in conn.scopes } - } ?: false - } ?: false - - if (isNewConnectionForQ) { - openMeetQPage(project) - } - - QWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AmazonQ)) - - // isQConnected alone is not robust and there is race condition (read/update connection states) - val component = if (isNewConnectionForQ || (isQConnected(project) && !isQExpired(project))) { - LOG.debug { "returning Q-chat window; isQConnection=$isNewConnectionForQ; hasPinnedConnection=$isNewConnectionForQ" } - AmazonQToolWindow.getInstance(project).component - } else { - LOG.debug { "returning login window; no Q connection found" } - QWebviewPanel.getInstance(project).component - } - runInEdt { - qPanel.setContent(component) - } - } - companion object { private val LOG = getLogger() const val WINDOW_ID = AMAZON_Q_WINDOW_ID diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt index a62c8e1191f..40f2b76baa2 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt @@ -8,6 +8,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.jcef.JBCefJSQuery import software.aws.toolkits.jetbrains.core.webview.LocalAssetJBCefRequestHandler +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile import software.aws.toolkits.jetbrains.services.amazonq.util.HighlightCommand import software.aws.toolkits.jetbrains.services.amazonq.util.createBrowser import software.aws.toolkits.jetbrains.settings.MeetQSettings @@ -40,8 +41,9 @@ class Browser(parent: Disposable) : Disposable { isCodeScanAvailable: Boolean, isCodeTestAvailable: Boolean, highlightCommand: HighlightCommand?, + activeProfile: QRegionProfile?, ) { - loadWebView(isCodeTransformAvailable, isFeatureDevAvailable, isDocAvailable, isCodeScanAvailable, isCodeTestAvailable, highlightCommand) + loadWebView(isCodeTransformAvailable, isFeatureDevAvailable, isDocAvailable, isCodeScanAvailable, isCodeTestAvailable, highlightCommand, activeProfile) } override fun dispose() { @@ -63,6 +65,7 @@ class Browser(parent: Disposable) : Disposable { isCodeScanAvailable: Boolean, isCodeTestAvailable: Boolean, highlightCommand: HighlightCommand?, + activeProfile: QRegionProfile?, ) { // setup empty state. The message request handlers use this for storing state // that's persistent between page loads. @@ -72,7 +75,15 @@ class Browser(parent: Disposable) : Disposable { jcefBrowser.loadURL( assetRequestHandler.createResource( "webview/chat.html", - getWebviewHTML(isCodeTransformAvailable, isFeatureDevAvailable, isDocAvailable, isCodeScanAvailable, isCodeTestAvailable, highlightCommand) + getWebviewHTML( + isCodeTransformAvailable, + isFeatureDevAvailable, + isDocAvailable, + isCodeScanAvailable, + isCodeTestAvailable, + highlightCommand, + activeProfile, + ) ) ) } @@ -88,6 +99,7 @@ class Browser(parent: Disposable) : Disposable { isCodeScanAvailable: Boolean, isCodeTestAvailable: Boolean, highlightCommand: HighlightCommand?, + activeProfile: QRegionProfile?, ): String { val postMessageToJavaJsCode = receiveMessageQuery.inject("JSON.stringify(message)") @@ -108,7 +120,8 @@ class Browser(parent: Disposable) : Disposable { $isDocAvailable, // whether /doc is available $isCodeScanAvailable, // whether /scan is available $isCodeTestAvailable, // whether /test is available - ${OBJECT_MAPPER.writeValueAsString(highlightCommand)} + ${OBJECT_MAPPER.writeValueAsString(highlightCommand)}, + "${activeProfile?.profileName.orEmpty()}" ); } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatApp.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatApp.kt index 166223d623a..0aa8dc42b04 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatApp.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatApp.kt @@ -21,6 +21,8 @@ import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth.isCodeScanAvailable import software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands.CodeScanActionMessage import software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands.CodeScanMessageListener @@ -141,6 +143,15 @@ class CodeScanChatApp(private val scope: CoroutineScope) : AmazonQApp { } } ) + + context.project.messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + chatSessionStorage.deleteAllSessions() + } + } + ) } private fun getQTokenProvider(project: Project) = ( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/storage/ChatSessionStorage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/storage/ChatSessionStorage.kt index fb05a7beda8..57dcb85f44a 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/storage/ChatSessionStorage.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/storage/ChatSessionStorage.kt @@ -30,4 +30,8 @@ class ChatSessionStorage { fun changeAuthenticationNeededNotified(authNeededNotified: Boolean) { sessions.keys.forEach { sessions[it]?.authNeededNotified = authNeededNotified } } + + fun deleteAllSessions() { + sessions.clear() + } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeTestChatApp.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeTestChatApp.kt index 9da12272cdc..7972e45fb9c 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeTestChatApp.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeTestChatApp.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.amazonqCodeTest import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection @@ -11,6 +12,8 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth.isCodeScanAvailable import software.aws.toolkits.jetbrains.services.amazonqCodeTest.auth.isCodeTestAvailable import software.aws.toolkits.jetbrains.services.amazonqCodeTest.controller.CodeTestChatController @@ -71,6 +74,15 @@ class CodeTestChatApp(private val scope: CoroutineScope) : AmazonQApp { } } ) + + context.project.messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + chatSessionStorage.deleteAllSessions() + } + } + ) } private suspend fun handleMessage(message: AmazonQMessage, inboundAppMessagesHandler: InboundAppMessagesHandler) { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeWhispererUTGChatManager.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeWhispererUTGChatManager.kt index dc60a60e747..aecede14c15 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeWhispererUTGChatManager.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/CodeWhispererUTGChatManager.kt @@ -541,8 +541,8 @@ Please see the unit tests generated below. Click 'View Diff' to review the chang jobGroup = session.testGenerationJobGroupName, jobId = session.testGenerationJob, result = if (e.message == message("testgen.message.cancelled")) MetricResult.Cancelled else MetricResult.Failed, - reason = (e as CodeTestException).code ?: "DefaultError", - reasonDesc = if (e.message == message("testgen.message.cancelled")) "${e.code}: ${e.message}" else e.message, + reason = (e as? CodeTestException)?.code ?: "DefaultError", + reasonDesc = if (e.message == message("testgen.message.cancelled")) "${(e as? CodeTestException)?.code}: ${e.message}" else e.message, perfClientLatency = (Instant.now().toEpochMilli() - session.startTimeOfTestGeneration), isCodeBlockSelected = session.isCodeBlockSelected, artifactsUploadDuration = session.artifactUploadDuration, diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/controller/CodeTestChatController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/controller/CodeTestChatController.kt index f146d0791b9..f83bb1e1649 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/controller/CodeTestChatController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/controller/CodeTestChatController.kt @@ -52,13 +52,11 @@ 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.AwsClientManager import software.aws.toolkits.jetbrains.core.coroutines.EDT -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.isInternalUser import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonq.project.RelevantDocument import software.aws.toolkits.jetbrains.services.amazonqCodeTest.CodeWhispererUTGChatManager import software.aws.toolkits.jetbrains.services.amazonqCodeTest.ConversationState @@ -249,26 +247,22 @@ class CodeTestChatController( }) } .build() + if (!fileInfo.fileInWorkspace) { + val messageContent = + " I can't generate tests for ${fileInfo.fileName}" + + " because it's outside the project directory.
" + + "I can still provide examples, instructions and code suggestions." - val messageContent = if (fileInfo.fileInWorkspace) { - " ${fileInfo.fileLanguage.languageId} is not a " + - "language I support specialized unit test generation for at the moment.
The languages " + - "I support now are Python and Java. I can still provide examples, instructions and code suggestions." - } else { - " I can't generate tests for ${fileInfo.fileName}" + - " because it's outside the project directory.
" + - "I can still provide examples, instructions and code suggestions." + codeTestChatHelper.addNewMessage( + CodeTestChatMessageContent( + message = messageContent, + type = ChatMessageType.Answer, + canBeVoted = false + ), + message.tabId, + false + ) } - - codeTestChatHelper.addNewMessage( - CodeTestChatMessageContent( - message = messageContent, - type = ChatMessageType.Answer, - canBeVoted = false - ), - message.tabId, - false - ) testResponseMessageId = codeTestChatHelper.addAnswer( CodeTestChatMessageContent( message = "", @@ -280,9 +274,6 @@ class CodeTestChatController( promptInputDisabledState = true, ) // Send Request to Sync UTG API - val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) - // this should never happen because it should have been handled upstream by [AuthController] - ?: error("connection was found to be null") val contextExtractor = ActiveFileContextExtractor.create(fqnWebviewAdapter = null, project = project) val activeFileContext = ActiveFileContext( fileContext = FileContext( @@ -304,7 +295,7 @@ class CodeTestChatController( useRelevantDocuments = false, ) - val client = AwsClientManager.getInstance().getClient(connection.getConnectionSettings()) + val client = QRegionProfileManager.getInstance().getQClient(project) val request = requestData.toChatRequest() client.generateAssistantResponse(request, responseHandler).await() // TODO: Need to send isCodeBlockSelected field @@ -417,6 +408,7 @@ class CodeTestChatController( .build() return GenerateAssistantResponseRequest.builder() .conversationState(conversationState) + .profileArn(QRegionProfileManager.getInstance().activeProfile(context.project)?.arn) .build() } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/storage/ChatSessionStorage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/storage/ChatSessionStorage.kt index 0e38f06c6de..295bd9abe15 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/storage/ChatSessionStorage.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeTest/storage/ChatSessionStorage.kt @@ -17,4 +17,8 @@ class ChatSessionStorage { // Find all sessions that are currently waiting to be authenticated fun getAuthenticatingSessions(): List = this.sessions.values.filter { it.isAuthenticating } + + fun deleteAllSessions() { + sessions.clear() + } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/DocApp.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/DocApp.kt index 0e55a655579..ed913374e08 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/DocApp.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/DocApp.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.amazonqDoc import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection @@ -11,6 +12,8 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth.isCodeScanAvailable import software.aws.toolkits.jetbrains.services.amazonqCodeTest.auth.isCodeTestAvailable import software.aws.toolkits.jetbrains.services.amazonqDoc.auth.isDocAvailable @@ -74,6 +77,15 @@ class DocApp : AmazonQApp { } } ) + + context.project.messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + chatSessionStorage.deleteAllSessions() + } + } + ) } private suspend fun handleMessage(message: AmazonQMessage, inboundAppMessagesHandler: InboundAppMessagesHandler) { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/storage/ChatSessionStorage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/storage/ChatSessionStorage.kt index 2344dac5c94..e54a6b1d7fb 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/storage/ChatSessionStorage.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/storage/ChatSessionStorage.kt @@ -23,4 +23,11 @@ class ChatSessionStorage { // Find all sessions that are currently waiting to be authenticated fun getAuthenticatingSessions(): List = this.sessions.values.filter { it.isAuthenticating } + + fun deleteAllSessions() { + sessions.values.forEach { session -> + session.sessionState.token?.cancel() + } + sessions.clear() + } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt index 7169e391507..c9fe08efc64 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.amazonqFeatureDev import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection @@ -11,6 +12,8 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth.isCodeScanAvailable import software.aws.toolkits.jetbrains.services.amazonqCodeTest.auth.isCodeTestAvailable import software.aws.toolkits.jetbrains.services.amazonqDoc.auth.isDocAvailable @@ -75,6 +78,15 @@ class FeatureDevApp : AmazonQApp { } } ) + + context.project.messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + chatSessionStorage.deleteAllSessions() + } + } + ) } private suspend fun handleMessage(message: AmazonQMessage, inboundAppMessagesHandler: InboundAppMessagesHandler) { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/clients/FeatureDevClient.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/clients/FeatureDevClient.kt index 2d73f5fb4ba..7b25fb3b00e 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/clients/FeatureDevClient.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/clients/FeatureDevClient.kt @@ -28,10 +28,8 @@ import software.amazon.awssdk.services.codewhispererstreaming.model.ExportIntent 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.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.services.amazonq.clients.AmazonQStreamingClient +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FEATURE_EVALUATION_PRODUCT_NAME import software.aws.toolkits.jetbrains.services.codemodernizer.utils.calculateTotalLatency import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata @@ -70,11 +68,7 @@ class FeatureDevClient( .build() } - private fun connection() = - ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) - ?: error("Attempted to use connection while one does not exist") - - private fun bearerClient() = connection().getConnectionSettings().awsClient() + private fun bearerClient() = QRegionProfileManager.getInstance().getQClient(project) private val amazonQStreamingClient get() = AmazonQStreamingClient.getInstance(project) @@ -88,6 +82,7 @@ class FeatureDevClient( } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(featureDevUserContext) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun sendFeatureDevMetricData(operationName: String, result: String): SendTelemetryEventResponse = @@ -115,6 +110,7 @@ class FeatureDevClient( } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(featureDevUserContext) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun sendFeatureDevCodeGenerationEvent( @@ -133,6 +129,7 @@ class FeatureDevClient( } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(featureDevUserContext) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun sendFeatureDevCodeAcceptanceEvent( @@ -151,11 +148,14 @@ class FeatureDevClient( } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(featureDevUserContext) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun createTaskAssistConversation(): CreateTaskAssistConversationResponse = bearerClient().createTaskAssistConversation( - CreateTaskAssistConversationRequest.builder().build(), + CreateTaskAssistConversationRequest.builder() + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) + .build(), ) fun createTaskAssistUploadUrl( @@ -182,6 +182,7 @@ class FeatureDevClient( .build(), ).build(), ) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun startTaskAssistCodeGeneration( @@ -205,6 +206,7 @@ class FeatureDevClient( .uploadId(uploadId) }.codeGenerationId(codeGenerationId.toString()) .currentCodeGenerationId(currentCodeGenerationId) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } fun getTaskAssistCodeGeneration( @@ -216,6 +218,7 @@ class FeatureDevClient( it .conversationId(conversationId) .codeGenerationId(codeGenerationId) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } suspend fun exportTaskAssistResultArchive(conversationId: String): MutableList = diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt index 9401d8cacbd..75e1cf0fbd0 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt @@ -32,6 +32,8 @@ import software.aws.toolkits.jetbrains.core.coroutines.EDT import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonq.project.RepoSizeError import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.CodeIterationLimitException @@ -94,6 +96,17 @@ class FeatureDevController( private val authController: AuthController = AuthController(), ) : InboundAppMessagesHandler { + init { + context.project.messageBus.connect().subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + chatSessionStorage.deleteAllSessions() + } + } + ) + } + val messenger = context.messagesFromAppToUi val toolWindow = ToolWindowManager.getInstance(context.project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) @@ -466,7 +479,7 @@ class FeatureDevController( ), ) - if (!session.context.checkForDevFile()) { + if (!session.context.hasDevFile()) { followUps.add( FollowUp( pillText = message("amazonqFeatureDev.follow_up.generate_dev_file"), @@ -728,7 +741,7 @@ class FeatureDevController( } val codeWhispererSettings = CodeWhispererSettings.getInstance().getAutoBuildSetting() - val hasDevFile = session.context.checkForDevFile() + val hasDevFile = session.context.hasDevFile() val isPromptedForAutoBuildFeature = codeWhispererSettings.containsKey(session.context.workspaceRoot.path) if (hasDevFile && !isPromptedForAutoBuildFeature) { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt index 1c664733869..5448a51b55e 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt @@ -30,6 +30,7 @@ import software.aws.toolkits.telemetry.MetricResult import java.util.UUID private val logger = getLogger() +private const val RUN_COMMAND_LOG_PATH = ".amazonq/dev/run_command.log" class CodeGenerationState( override val tabID: String, @@ -211,9 +212,23 @@ private suspend fun CodeGenerationState.generateCode( conversationId = config.conversationId, ) - val newFileInfo = registerNewFiles(newFileContents = codeGenerationStreamResult.new_file_contents) - val deletedFileInfo = registerDeletedFiles(deletedFiles = codeGenerationStreamResult.deleted_files) + val fileContents = codeGenerationStreamResult.new_file_contents.filterKeys { file -> + if (file.endsWith(RUN_COMMAND_LOG_PATH)) { + val contents: String = codeGenerationStreamResult.new_file_contents[file].orEmpty() + val truncatedContents = if (contents.length > 10000000) { + contents.substring(0, 10000000) + } else { + contents + } + logger.info(truncatedContents) { "Run command log: $truncatedContents" } + false + } else { + true + } + } + val newFileInfo = registerNewFiles(newFileContents = fileContents) + val deletedFileInfo = registerDeletedFiles(deletedFiles = codeGenerationStreamResult.deleted_files) return CodeGenerationResult( newFiles = newFileInfo, deletedFiles = deletedFileInfo, diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/storage/ChatSessionStorage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/storage/ChatSessionStorage.kt index 216d9cd1c8b..ed0f7eab7be 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/storage/ChatSessionStorage.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/storage/ChatSessionStorage.kt @@ -23,4 +23,11 @@ class ChatSessionStorage { // Find all sessions that are currently waiting to be authenticated fun getAuthenticatingSessions(): List = this.sessions.values.filter { it.isAuthenticating } + + fun deleteAllSessions() { + sessions.values.forEach { session -> + session.sessionState.token?.cancel() + } + sessions.clear() + } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/App.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/App.kt index b206595f9e8..34bb6bca614 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/App.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/App.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.cwc import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope @@ -12,6 +13,8 @@ import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonq.util.highlightCommand import software.aws.toolkits.jetbrains.services.cwc.commands.ActionRegistrar import software.aws.toolkits.jetbrains.services.cwc.commands.CodeScanIssueActionMessage @@ -75,6 +78,15 @@ class App : AmazonQApp { } } ) + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + inboundAppMessagesHandler.processSessionClear() + } + } + ) } private suspend fun handleMessage(message: AmazonQMessage, inboundAppMessagesHandler: InboundAppMessagesHandler) { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/InboundAppMessagesHandler.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/InboundAppMessagesHandler.kt index ff8a12e70ec..bbcddd84552 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/InboundAppMessagesHandler.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/InboundAppMessagesHandler.kt @@ -34,4 +34,6 @@ interface InboundAppMessagesHandler { suspend fun processCodeScanIssueAction(message: CodeScanIssueActionMessage) suspend fun processLinkClick(message: IncomingCwcMessage.ClickedLink) + + fun processSessionClear() } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt index a55d9cc2075..e8d967a8e65 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt @@ -36,10 +36,8 @@ import software.amazon.awssdk.services.codewhispererstreaming.model.UserInputMes import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info -import software.aws.toolkits.jetbrains.core.AwsClientManager import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonq.project.RelevantDocument import software.aws.toolkits.jetbrains.services.cwc.ChatConstants import software.aws.toolkits.jetbrains.services.cwc.clients.chat.ChatSession @@ -170,11 +168,7 @@ class ChatSessionV1( try { withTimeout(ChatConstants.REQUEST_TIMEOUT_MS.toLong()) { - val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) - // this should never happen because it should have been handled upstream by [AuthController] - ?: error("connection was found to be null") - - val client = AwsClientManager.getInstance().getClient(connection.getConnectionSettings()) + val client = QRegionProfileManager.getInstance().getQClient(project) val request = data.toChatRequest() logger.info { "Request from tab: ${data.tabId}, conversationId: $conversationId, request: $request" } client.generateAssistantResponse(request, responseHandler).await() @@ -216,6 +210,7 @@ class ChatSessionV1( .build() return GenerateAssistantResponseRequest.builder() .conversationState(conversationState) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) .build() } @@ -300,6 +295,8 @@ class ChatSessionV1( UserIntent.EXPLAIN_CODE_SELECTION -> FollowUpType.ExplainInDetail UserIntent.UNKNOWN_TO_SDK_VERSION -> FollowUpType.Generated UserIntent.GENERATE_UNIT_TESTS -> FollowUpType.Generated + UserIntent.GENERATE_CLOUDFORMATION_TEMPLATE -> FollowUpType.Generated + UserIntent.CODE_GENERATION -> FollowUpType.Generated null -> FollowUpType.Generated } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToQActionGroup.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToQActionGroup.kt index d3e77515cd1..d903f842020 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToQActionGroup.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToQActionGroup.kt @@ -8,6 +8,7 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.project.DumbAware import com.intellij.openapi.wm.ToolWindowManager +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory class SendToQActionGroup : DefaultActionGroup(), DumbAware { @@ -16,6 +17,7 @@ class SendToQActionGroup : DefaultActionGroup(), DumbAware { override fun update(e: AnActionEvent) { val project = e.project ?: return val amazonQWindow = ToolWindowManager.getInstance(project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) - e.presentation.isEnabledAndVisible = amazonQWindow?.isAvailable ?: false + e.presentation.isEnabledAndVisible = (amazonQWindow?.isAvailable == true) && + !QRegionProfileManager.getInstance().hasValidConnectionButNoActiveProfile(project) } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt index 84ebaa60cef..30d3aeeba16 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt @@ -541,6 +541,10 @@ class ChatController private constructor( .map { it.tabId } .first() + override fun processSessionClear() { + chatSessionStorage.deleteAllSessions() + } + companion object { private val logger = getLogger() diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt index de137756bff..4d7cff9667f 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt @@ -64,6 +64,8 @@ class TelemetryHelper(private val project: Project, private val sessionStorage: UserIntent.EXPLAIN_CODE_SELECTION -> CwsprChatUserIntent.ExplainCodeSelection UserIntent.GENERATE_UNIT_TESTS -> CwsprChatUserIntent.GenerateUnitTests UserIntent.UNKNOWN_TO_SDK_VERSION -> CwsprChatUserIntent.Unknown + UserIntent.GENERATE_CLOUDFORMATION_TEMPLATE -> CwsprChatUserIntent.Unknown + UserIntent.CODE_GENERATION -> CwsprChatUserIntent.Unknown } private fun getTelemetryTriggerType(triggerType: TriggerType): CwsprChatTriggerInteraction = when (triggerType) { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatController.kt index 1a6f8d2d10f..bf468976a2f 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatController.kt @@ -55,6 +55,8 @@ import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsFor import software.aws.toolkits.jetbrains.core.webview.BrowserState import software.aws.toolkits.jetbrains.services.amazonq.QWebviewPanel import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AMAZON_Q_WINDOW_ID import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition @@ -101,6 +103,14 @@ class InlineChatController( init { Disposer.register(this, listener) project.messageBus.connect(this).subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, listener) + project.messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + sessionStorage.deleteAllSessions() + } + } + ) } data class InlineChatMetrics( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/OpenChatInputAction.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/OpenChatInputAction.kt index ebc6b53cd13..f581a217226 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/OpenChatInputAction.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/OpenChatInputAction.kt @@ -3,10 +3,12 @@ package software.aws.toolkits.jetbrains.services.cwc.inline +import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.util.Key +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager class OpenChatInputAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { @@ -24,4 +26,9 @@ class OpenChatInputAction : AnAction() { val inlineChatController = InlineChatController.getInstance(project) inlineChatController.initPopup(editor) } + override fun update(e: AnActionEvent) { + val project = e.project ?: return + e.presentation.isEnabledAndVisible = !QRegionProfileManager.getInstance().hasValidConnectionButNoActiveProfile(project) + } + override fun getActionUpdateThread() = ActionUpdateThread.BGT } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionStorage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionStorage.kt index 231cdd21d5f..e11d0693b7c 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionStorage.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionStorage.kt @@ -26,4 +26,8 @@ class ChatSessionStorage( fun deleteSession(tabId: String) { sessions.remove(tabId)?.scope?.cancel() } + + fun deleteAllSessions() { + sessions.clear() + } } diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevTestBase.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevTestBase.kt index 6c1a2fb6389..da392cd08cd 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevTestBase.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevTestBase.kt @@ -65,6 +65,8 @@ open class FeatureDevTestBase( internal val otherStatus = "Other" internal val testTabId = "test-tab-id" internal val testFilePaths = mapOf(Pair("test.ts", "This is a comment")) + internal val testRunCommandLogPath = ".amazonq/dev/run_command.log" + internal val testLogPath = mapOf(Pair(testRunCommandLogPath, "This is a log")) internal val testDeletedFiles = listOf("deleted.ts") internal val testReferences = listOf(CodeReferenceGenerated()) internal val testChecksumSha = "test-sha" diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt index 45d613f6b24..8e60630ac21 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt @@ -39,13 +39,14 @@ class CodeGenerationStateTest : FeatureDevTestBase() { private lateinit var messenger: MessagePublisher private val action = SessionStateAction("test-task", userMessage) private lateinit var featureDevService: FeatureDevService + private lateinit var repoContext: FeatureDevSessionContext @Before override fun setup() { featureDevService = mockk() every { featureDevService.project } returns projectRule.project messenger = mock() - val repoContext = mock() + repoContext = mockk() val sessionStateConfig = SessionStateConfig(testConversationId, repoContext, featureDevService) codeGenerationState = @@ -103,6 +104,34 @@ class CodeGenerationStateTest : FeatureDevTestBase() { coVerify(exactly = 1) { featureDevService.exportTaskAssistArchiveResult(testConversationId) } } + @Test + fun `test generateCode excludes run_command log file`() { + val runCommandLogFileName = "run_command.log" + + val archiveFiles = mapOf( + runCommandLogFileName to "newLog", + "other.ts" to "other content" + ) + val deletedFiles = emptyList() + val references = emptyList() + + every { featureDevService.getTaskAssistCodeGeneration(any(), any()) } returns exampleCompleteGetTaskAssistCodeGenerationResponse + every { featureDevService.startTaskAssistCodeGeneration(any(), any(), any(), any(), any()) } returns exampleStartTaskAssistConversationResponse + coEvery { featureDevService.exportTaskAssistArchiveResult(any()) } returns + CodeGenerationStreamResult(archiveFiles, listOf("deleted.ts"), listOf(CodeReferenceGenerated())) + + runTest { + val actual = codeGenerationState.interact(action) + val nextState = actual.nextState as PrepareCodeGenerationState + assertThat(nextState.filePaths).contains( + NewFileZipInfo(runCommandLogFileName, "newLog", rejected = false, changeApplied = false) + ) + assertThat(nextState.filePaths).contains( + NewFileZipInfo("other.ts", "other content", rejected = false, changeApplied = false) + ) + } + } + @Test(expected = FeatureDevException::class) fun `test code generation failed`() = runTest { diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ArtifactHandler.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ArtifactHandler.kt index a55c2488fe8..93d3da48e7e 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ArtifactHandler.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ArtifactHandler.kt @@ -528,6 +528,7 @@ class ArtifactHandler( TransformationDownloadArtifactType.CLIENT_INSTRUCTIONS -> CodeTransformArtifactType.ClientInstructions TransformationDownloadArtifactType.LOGS -> CodeTransformArtifactType.Logs TransformationDownloadArtifactType.UNKNOWN_TO_SDK_VERSION -> CodeTransformArtifactType.Unknown + TransformationDownloadArtifactType.GENERATED_CODE -> CodeTransformArtifactType.Unknown } companion object { diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerManager.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerManager.kt index c2038db7a86..9549cb8578d 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerManager.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerManager.kt @@ -32,6 +32,8 @@ import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope import software.aws.toolkits.jetbrains.services.amazonq.CODE_TRANSFORM_TROUBLESHOOT_DOC_MVN_FAILURE import software.aws.toolkits.jetbrains.services.amazonq.CODE_TRANSFORM_TROUBLESHOOT_DOC_PROJECT_SIZE +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.codemodernizer.client.GumbyClient import software.aws.toolkits.jetbrains.services.codemodernizer.commands.CodeTransformMessageListener import software.aws.toolkits.jetbrains.services.codemodernizer.constants.HIL_POM_FILE_NAME @@ -129,6 +131,23 @@ class CodeModernizerManager(private val project: Project) : PersistentStateCompo init { CodeModernizerSessionState.getInstance(project).setDefaults() + initQRegionProfileSelectedListener() + } + + private fun initQRegionProfileSelectedListener() { + project.messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + stopModernize() + codeTransformationSession?.let { + Disposer.dispose(it) + } + managerState = CodeModernizerState() + codeTransformationSession = null + } + } + ) } fun validate(project: Project, transformationType: CodeTransformType): ValidationResult { @@ -250,7 +269,7 @@ class CodeModernizerManager(private val project: Project) : PersistentStateCompo fun runModernize(copyResult: MavenCopyCommandsResult? = null) { initStopParameters() - val session = codeTransformationSession as CodeModernizerSession + val session = codeTransformationSession ?: return initModernizationJobUI(true, project.getModuleOrProjectNameForFile(session.sessionContext.configurationFile)) launchModernizationJob(session, copyResult) } diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt index 9ba17c22cb1..921a9ac0f2c 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt @@ -5,6 +5,7 @@ package software.aws.toolkits.jetbrains.services.codemodernizer import com.intellij.openapi.Disposable import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.util.Disposer import com.intellij.serviceContainer.AlreadyDisposedException import com.intellij.util.io.HttpRequests import kotlinx.coroutines.delay @@ -149,6 +150,9 @@ class CodeModernizerSession( * Based on [CodeWhispererCodeScanSession] */ suspend fun createModernizationJob(copyResult: MavenCopyCommandsResult?): CodeModernizerStartJobResult { + if (this.isDisposed.get()) { + return CodeModernizerStartJobResult.Cancelled + } LOG.info { "Compressing local project" } val payload: File? var payloadSize = 0 @@ -182,6 +186,9 @@ class CodeModernizerSession( payloadSize = payload.length().toInt() LOG.info { "Uploading zip file with size: $payloadSize bytes" } + if (this.isDisposed.get()) { + return CodeModernizerStartJobResult.Cancelled + } if (payloadSize > MAX_ZIP_SIZE) { telemetryErrorMessage = "Project exceeds max upload size" @@ -210,7 +217,7 @@ class CodeModernizerSession( telemetryErrorMessage = "Credential expired before uploading project" return CodeModernizerStartJobResult.ZipUploadFailed(UploadFailureReason.CREDENTIALS_EXPIRED) } - if (shouldStop.get()) { + if (shouldStop.get() || this.isDisposed.get()) { LOG.warn { "Job was cancelled by user before upload was called" } telemetryErrorMessage = "Cancelled when about to upload project" return CodeModernizerStartJobResult.Cancelled @@ -285,7 +292,7 @@ class CodeModernizerSession( CodeTransformMessageListener.instance.onUploadResult() return try { - if (shouldStop.get()) { + if (shouldStop.get() || this.isDisposed.get()) { LOG.warn { "Job was cancelled by user before start job was called" } return CodeModernizerStartJobResult.Cancelled } @@ -625,6 +632,8 @@ class CodeModernizerSession( override fun dispose() { isDisposed.set(true) + shouldStop.set(true) + Disposer.dispose(sessionContext) } fun getActiveJobId() = state.currentJobId diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeTransformChatApp.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeTransformChatApp.kt index 1c32f2f07c5..b102d348d11 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeTransformChatApp.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeTransformChatApp.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.codemodernizer import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope @@ -15,6 +16,8 @@ import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth.isCodeScanAvailable import software.aws.toolkits.jetbrains.services.amazonqCodeTest.auth.isCodeTestAvailable import software.aws.toolkits.jetbrains.services.amazonqDoc.auth.isDocAvailable @@ -161,6 +164,15 @@ class CodeTransformChatApp : AmazonQApp { } } ) + + context.project.messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + chatSessionStorage.deleteAllSessions() + } + } + ) } private suspend fun handleMessage(message: AmazonQMessage, inboundAppMessagesHandler: InboundAppMessagesHandler) { diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt index 097b9231d4a..6134e69ca22 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt @@ -38,9 +38,6 @@ 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.AwsClientManager -import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.services.amazonq.APPLICATION_ZIP import software.aws.toolkits.jetbrains.services.amazonq.AWS_KMS import software.aws.toolkits.jetbrains.services.amazonq.CONTENT_SHA256 @@ -48,6 +45,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.SERVER_SIDE_ENCRYPTION import software.aws.toolkits.jetbrains.services.amazonq.SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID import software.aws.toolkits.jetbrains.services.amazonq.clients.AmazonQStreamingClient import software.aws.toolkits.jetbrains.services.amazonq.codeWhispererUserContext +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerMetrics import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId import software.aws.toolkits.jetbrains.services.codemodernizer.utils.calculateTotalLatency @@ -58,10 +56,7 @@ import java.time.Instant @Service(Service.Level.PROJECT) class GumbyClient(private val project: Project) { - private fun connection() = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) - ?: error("Attempted to use connection while one does not exist") - - private fun bearerClient() = connection().getConnectionSettings().awsClient() + private fun bearerClient() = QRegionProfileManager.getInstance().getQClient(project) private val amazonQStreamingClient get() = AmazonQStreamingClient.getInstance(project) @@ -71,6 +66,7 @@ class GumbyClient(private val project: Project) { .contentChecksumType(ContentChecksumType.SHA_256) .contentChecksum(sha256Checksum) .uploadIntent(UploadIntent.TRANSFORMATION) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) .build() return callApi({ bearerClient().createUploadUrl(request) }, apiName = "CreateUploadUrl") } @@ -92,12 +88,16 @@ class GumbyClient(private val project: Project) { ) .build() ) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) .build() return callApi({ bearerClient().createUploadUrl(request) }, apiName = "CreateUploadUrl") } fun getCodeModernizationJob(jobId: String): GetTransformationResponse { - val request = GetTransformationRequest.builder().transformationJobId(jobId).build() + val request = GetTransformationRequest.builder() + .transformationJobId(jobId) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) + .build() return callApi({ bearerClient().getTransformation(request) }, apiName = "GetTransformation") } @@ -116,6 +116,7 @@ class GumbyClient(private val project: Project) { .source { it.language(sourceLanguage) } .target { it.language(targetLanguage) } } + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) .build() return callApi({ bearerClient().startTransformation(request) }, apiName = "StartTransformation") } @@ -127,17 +128,22 @@ class GumbyClient(private val project: Project) { val request = ResumeTransformationRequest.builder() .transformationJobId(jobId.id) .userActionStatus(userActionStatus) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) .build() return callApi({ bearerClient().resumeTransformation(request) }, apiName = "ResumeTransformation") } fun getCodeModernizationPlan(jobId: JobId): GetTransformationPlanResponse { - val request = GetTransformationPlanRequest.builder().transformationJobId(jobId.id).build() + val request = GetTransformationPlanRequest.builder() + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) + .transformationJobId(jobId.id).build() return callApi({ bearerClient().getTransformationPlan(request) }, apiName = "GetTransformationPlan") } fun stopTransformation(transformationJobId: String): StopTransformationResponse { - val request = StopTransformationRequest.builder().transformationJobId(transformationJobId).build() + val request = StopTransformationRequest.builder().transformationJobId(transformationJobId) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) + .build() return callApi({ bearerClient().stopTransformation(request) }, apiName = "StopTransformation") } @@ -232,6 +238,7 @@ class GumbyClient(private val project: Project) { } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } } diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/constants/CodeTransformChatItems.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/constants/CodeTransformChatItems.kt index 4584ca1e985..c39d530e6b2 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/constants/CodeTransformChatItems.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/constants/CodeTransformChatItems.kt @@ -778,4 +778,5 @@ fun getDownloadedArtifactTextFromType(artifactType: TransformationDownloadArtifa TransformationDownloadArtifactType.CLIENT_INSTRUCTIONS -> "upgraded code" TransformationDownloadArtifactType.LOGS -> "build log" TransformationDownloadArtifactType.UNKNOWN_TO_SDK_VERSION -> "code" + TransformationDownloadArtifactType.GENERATED_CODE -> "code" } diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/MavenRunnerUtils.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/MavenRunnerUtils.kt index 74ec6aeae3d..6629b8c2d34 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/MavenRunnerUtils.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/MavenRunnerUtils.kt @@ -16,6 +16,7 @@ import org.slf4j.Logger import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.info import software.aws.toolkits.jetbrains.services.codemodernizer.CodeTransformTelemetryManager +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerSessionContext import software.aws.toolkits.jetbrains.services.codemodernizer.model.MavenCopyCommandsResult import software.aws.toolkits.jetbrains.services.codemodernizer.model.MavenDependencyReportCommandsResult import software.aws.toolkits.telemetry.CodeTransformBuildCommand @@ -25,6 +26,7 @@ import java.nio.file.Files import java.nio.file.Path fun runHilMavenCopyDependency( + context: CodeModernizerSessionContext, sourceFolder: File, destinationDir: File, logBuilder: StringBuilder, @@ -35,6 +37,7 @@ fun runHilMavenCopyDependency( try { // Create shared parameters val transformMvnRunner = TransformMavenRunner(project) + context.mavenRunnerQueue.add(transformMvnRunner) val mvnSettings = MavenRunner.getInstance(project).settings.clone() // clone required to avoid editing user settings // run copy dependencies @@ -57,7 +60,14 @@ fun runHilMavenCopyDependency( return MavenCopyCommandsResult.Success(destinationDir) } -fun runMavenCopyCommands(sourceFolder: File, logBuilder: StringBuilder, logger: Logger, project: Project, shouldSkipTests: Boolean): MavenCopyCommandsResult { +fun runMavenCopyCommands( + context: CodeModernizerSessionContext, + sourceFolder: File, + logBuilder: StringBuilder, + logger: Logger, + project: Project, + shouldSkipTests: Boolean, +): MavenCopyCommandsResult { val currentTimestamp = System.currentTimeMillis() val destinationDir = Files.createTempDirectory("transformation_dependencies_temp_$currentTimestamp") val telemetry = CodeTransformTelemetryManager.getInstance(project) @@ -68,6 +78,7 @@ fun runMavenCopyCommands(sourceFolder: File, logBuilder: StringBuilder, logger: try { // Create shared parameters val transformMvnRunner = TransformMavenRunner(project) + context.mavenRunnerQueue.add(transformMvnRunner) val mvnSettings = MavenRunner.getInstance(project).settings.clone() // clone required to avoid editing user settings val sourceVirtualFile = LocalFileSystem.getInstance().findFileByIoFile(sourceFolder) @@ -282,10 +293,17 @@ private fun runMavenDependencyUpdatesReport( return dependencyUpdatesReportRunnable } -fun runDependencyReportCommands(sourceFolder: File, logBuilder: StringBuilder, logger: Logger, project: Project): MavenDependencyReportCommandsResult { +fun runDependencyReportCommands( + context: CodeModernizerSessionContext, + sourceFolder: File, + logBuilder: StringBuilder, + logger: Logger, + project: Project, +): MavenDependencyReportCommandsResult { logger.info { "Executing IntelliJ bundled Maven" } val transformMvnRunner = TransformMavenRunner(project) + context.mavenRunnerQueue.add(transformMvnRunner) val mvnSettings = MavenRunner.getInstance(project).settings.clone() // clone required to avoid editing user settings val runnable = runMavenDependencyUpdatesReport(sourceFolder, logBuilder, mvnSettings, transformMvnRunner, logger) diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformMavenRunner.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformMavenRunner.kt index f202cf6896f..a68c659ba6f 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformMavenRunner.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformMavenRunner.kt @@ -5,6 +5,7 @@ package software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven import com.intellij.execution.process.ProcessAdapter import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler import com.intellij.execution.process.ProcessOutputTypes import com.intellij.execution.runners.ProgramRunner import com.intellij.execution.ui.RunContentDescriptor @@ -16,11 +17,13 @@ import org.jetbrains.idea.maven.execution.MavenRunnerParameters import org.jetbrains.idea.maven.execution.MavenRunnerSettings class TransformMavenRunner(val project: Project) { + private var handler: ProcessHandler? = null fun run(parameters: MavenRunnerParameters, settings: MavenRunnerSettings, onComplete: TransformRunnable) { FileDocumentManager.getInstance().saveAllDocuments() val callback = ProgramRunner.Callback { descriptor: RunContentDescriptor -> val handler = descriptor.processHandler + this.handler = handler if (handler == null) { // add log error here onComplete.setExitCode(-1) @@ -50,4 +53,8 @@ class TransformMavenRunner(val project: Project) { // Setting isDelegateBuild = true allows us to set the JRE used by Maven during runtime MavenRunConfigurationType.runConfiguration(project, parameters, null, settings, callback, false) } + + fun cancel() { + this.handler?.destroyProcess() + } } diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerSessionContext.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerSessionContext.kt index 6a1a75b9d8a..a0da0824a76 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerSessionContext.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerSessionContext.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.codemodernizer.model import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.Disposable import com.intellij.openapi.application.runInEdt import com.intellij.openapi.application.runReadAction import com.intellij.openapi.project.Project @@ -19,6 +20,7 @@ import software.aws.toolkits.core.utils.putNextEntry import software.aws.toolkits.jetbrains.services.codemodernizer.EXPLAINABILITY_V1 import software.aws.toolkits.jetbrains.services.codemodernizer.constants.HIL_DEPENDENCIES_ROOT_NAME import software.aws.toolkits.jetbrains.services.codemodernizer.constants.HIL_MANIFEST_FILE_NAME +import software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven.TransformMavenRunner import software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven.runDependencyReportCommands import software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven.runHilMavenCopyDependency import software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven.runMavenCopyCommands @@ -59,6 +61,7 @@ const val INVALID_SUFFIX_REPOSITORIES = "repositories" const val ORACLE_DB = "ORACLE" const val AURORA_DB = "AURORA_POSTGRESQL" const val RDS_DB = "POSTGRESQL" + data class CodeModernizerSessionContext( val project: Project, var configurationFile: VirtualFile? = null, // used to ZIP module @@ -71,9 +74,11 @@ data class CodeModernizerSessionContext( val sourceServerName: String? = null, var schema: String? = null, val sqlMetadataZip: File? = null, -) { +) : Disposable { private val mapper = jacksonObjectMapper() private val ignoredDependencyFileExtensions = setOf(INVALID_SUFFIX_SHA, INVALID_SUFFIX_REPOSITORIES) + private var isDisposed = false + val mavenRunnerQueue: MutableList = mutableListOf() private fun File.isMavenTargetFolder(): Boolean { val hasPomSibling = this.resolveSibling(MAVEN_CONFIGURATION_FILE_NAME).exists() @@ -100,19 +105,22 @@ data class CodeModernizerSessionContext( } fun executeMavenCopyCommands(sourceFolder: File, buildLogBuilder: StringBuilder): MavenCopyCommandsResult { + if (isDisposed) return MavenCopyCommandsResult.Cancelled val shouldSkipTests = customBuildCommand == MAVEN_BUILD_SKIP_UNIT_TESTS - return runMavenCopyCommands(sourceFolder, buildLogBuilder, LOG, project, shouldSkipTests) + return runMavenCopyCommands(this, sourceFolder, buildLogBuilder, LOG, project, shouldSkipTests) } private fun executeHilMavenCopyDependency(sourceFolder: File, destinationFolder: File, buildLogBuilder: StringBuilder) = runHilMavenCopyDependency( + this, sourceFolder, destinationFolder, buildLogBuilder, LOG, - project + project, ) fun copyHilDependencyUsingMaven(hilTepDirPath: Path): MavenCopyCommandsResult { + if (isDisposed) return MavenCopyCommandsResult.Cancelled val sourceFolder = File(getPathToHilArtifactPomFolder(hilTepDirPath).pathString) val destinationFolder = Files.createDirectories(getPathToHilDependenciesRootDir(hilTepDirPath)).toFile() val buildLogBuilder = StringBuilder("Starting Build Log...\n") @@ -121,6 +129,7 @@ data class CodeModernizerSessionContext( } fun getDependenciesUsingMaven(): MavenCopyCommandsResult { + if (isDisposed) return MavenCopyCommandsResult.Cancelled val root = configurationFile?.parent val sourceFolder = File(root?.path) val buildLogBuilder = StringBuilder("Starting Build Log...\n") @@ -128,14 +137,16 @@ data class CodeModernizerSessionContext( } fun createDependencyReportUsingMaven(hilTempPomPath: Path): MavenDependencyReportCommandsResult { + if (isDisposed) return MavenDependencyReportCommandsResult.Cancelled val sourceFolder = File(hilTempPomPath.pathString) val buildLogBuilder = StringBuilder("Starting Build Log...\n") return executeDependencyVersionReportUsingMaven(sourceFolder, buildLogBuilder) } + private fun executeDependencyVersionReportUsingMaven( sourceFolder: File, buildLogBuilder: StringBuilder, - ) = runDependencyReportCommands(sourceFolder, buildLogBuilder, LOG, project) + ) = runDependencyReportCommands(this, sourceFolder, buildLogBuilder, LOG, project) fun createZipForHilUpload(hilTempPath: Path, manifest: CodeTransformHilDownloadManifest?, targetVersion: String): ZipCreationResult = runReadAction { @@ -326,6 +337,13 @@ data class CodeModernizerSessionContext( CodeModernizerBottomWindowPanelManager.getInstance(project).setJobStartingUI() } + override fun dispose() { + isDisposed = true + this.mavenRunnerQueue.forEach { + it.cancel() + } + } + companion object { private val LOG = getLogger() } diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/session/ChatSessionStorage.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/session/ChatSessionStorage.kt index 807e5dcbe1b..99198a56550 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/session/ChatSessionStorage.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/session/ChatSessionStorage.kt @@ -28,4 +28,8 @@ class ChatSessionStorage { fun changeAuthenticationNeededNotified(authNeededNotified: Boolean) { sessions.keys.forEach { sessions[it]?.authNeededNotified = authNeededNotified } } + + fun deleteAllSessions() { + sessions.clear() + } } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/resources/META-INF/plugin-codewhisperer.xml b/plugins/amazonq/codewhisperer/jetbrains-community/resources/META-INF/plugin-codewhisperer.xml index 07d8f29e713..f8a2de49534 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/resources/META-INF/plugin-codewhisperer.xml +++ b/plugins/amazonq/codewhisperer/jetbrains-community/resources/META-INF/plugin-codewhisperer.xml @@ -32,6 +32,7 @@ + + diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-241/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src-241/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt deleted file mode 100644 index 9c2319ed18a..00000000000 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src-241/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package software.aws.toolkits.jetbrains.services.codewhisperer.inlay - -import com.intellij.codeInsight.inline.completion.render.InlineBlockElementRenderer -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.EditorCustomElementRenderer -import com.intellij.openapi.editor.markup.TextAttributes -import com.intellij.xdebugger.ui.DebuggerColors - -// from 232-241.1, we have `InlineSuffixRenderer`, but with 241.2+ it becomes `InlineCompletionLineRenderer` -// for both line and block inlays. Also InlineBlockElementRenderer is deprecated -// 242 is not yet handled by this -object InlineCompletionRemoteRendererFactory { - private var hasOldLineConstructor = true - private val lineConstructor = run { - val clazz = - try { - Class.forName("com.intellij.codeInsight.inline.completion.render.InlineSuffixRenderer") - } catch (e: ClassNotFoundException) { - hasOldLineConstructor = false - Class.forName("com.intellij.codeInsight.inline.completion.render.InlineCompletionLineRenderer") - } - if (hasOldLineConstructor) { - clazz.getConstructor(Editor::class.java, String::class.java) - } else { - clazz.getConstructor(Editor::class.java, String::class.java, TextAttributes::class.java) - } - } - private var hasNewBlockConstructor = true - private val blockConstructor = run { - val clazz = - try { - Class.forName("com.intellij.codeInsight.inline.completion.render.InlineCompletionLineRenderer") - } catch (e: ClassNotFoundException) { - hasNewBlockConstructor = false - Class.forName("com.intellij.codeInsight.inline.completion.render.InlineBlockElementRenderer") - } - clazz.getConstructor(Editor::class.java, List::class.java) - } - - fun createLineInlay(editor: Editor, text: String): EditorCustomElementRenderer = - ( - if (hasOldLineConstructor) { - lineConstructor.newInstance(editor, text) - } else { - lineConstructor.newInstance(editor, text, editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE)) - } - ) as EditorCustomElementRenderer - - fun createBlockInlays(editor: Editor, block: List): List = - if (hasNewBlockConstructor) { - // 241.2+ - val textBlockClazz = Class.forName("com.intellij.codeInsight.inline.completion.render.InlineCompletionRenderTextBlock") - val textBlockConstructor = textBlockClazz.getConstructor(String::class.java, TextAttributes::class.java) - block.map { - blockConstructor.newInstance( - editor, - listOf(textBlockConstructor.newInstance(it, editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE))) - ) as EditorCustomElementRenderer - } - } else { - listOf(InlineBlockElementRenderer(editor, block)) - } -} diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/migration/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerActionManager.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/migration/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerActionManager.kt index 7c52a9c90b7..e5f07904e64 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/migration/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerActionManager.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/migration/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerActionManager.kt @@ -48,7 +48,7 @@ class CodeWhispererExplorerActionManager : PersistentStateComponent // Add range highlighters for all the issues found. runInEdt { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanEditorMouseMotionListener.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanEditorMouseMotionListener.kt index 5e993efc543..ef6d75d4fda 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanEditorMouseMotionListener.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanEditorMouseMotionListener.kt @@ -41,8 +41,8 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.utils.sen import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.utils.truncateIssueTitle import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService -import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.getHexString import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.getHexString import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.CodeFixAction import software.aws.toolkits.telemetry.MetricResult diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt index 36e60c6aa9f..aabefda3365 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt @@ -37,10 +37,8 @@ import software.amazon.awssdk.services.codewhispererruntime.model.TargetCode import software.amazon.awssdk.services.codewhispererruntime.model.UserIntent import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.services.amazonq.codeWhispererUserContext +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContextNew @@ -257,11 +255,8 @@ interface CodeWhispererClientAdaptor { } } -class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispererClientAdaptor { - fun bearerClient(): CodeWhispererRuntimeClient = - ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance())?.getConnectionSettings() - ?.awsClient() - ?: throw Exception("attempt to get bearer client while there is no valid credential") +open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispererClientAdaptor { + fun bearerClient() = QRegionProfileManager.getInstance().getQClient(project) override fun generateCompletionsPaginator(firstRequest: GenerateCompletionsRequest) = sequence { var nextToken: String? = firstRequest.nextToken() @@ -288,7 +283,9 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe // DO NOT directly use this method to fetch customizations, use wrapper [CodeWhispererModelConfigurator.listCustomization()] instead override fun listAvailableCustomizations(): List = - bearerClient().listAvailableCustomizationsPaginator(ListAvailableCustomizationsRequest.builder().build()) + bearerClient().listAvailableCustomizationsPaginator( + ListAvailableCustomizationsRequest.builder().profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn).build() + ) .stream() .toList() .flatMap { resp -> @@ -311,6 +308,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe builder.uploadId(uploadId) builder.targetCodeList(targetCode) builder.userInput(userInput) + builder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) // TODO: client token } @@ -318,6 +316,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe bearerClient().getTestGeneration { builder -> builder.testGenerationJobId(jobId) builder.testGenerationJobGroupName(jobGroupName) + builder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendUserTriggerDecisionTelemetry( @@ -363,6 +362,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } } @@ -409,6 +409,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } } @@ -435,6 +436,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendUserModificationTelemetry( @@ -462,6 +464,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendCodeScanTelemetry( @@ -481,6 +484,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendCodeScanSucceededTelemetry( @@ -503,6 +507,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendCodeScanFailedTelemetry( @@ -522,6 +527,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendCodeFixGenerationTelemetry( @@ -548,6 +554,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendCodeFixAcceptanceTelemetry( @@ -574,6 +581,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendCodeScanRemediationTelemetry( @@ -605,6 +613,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendTestGenerationEvent( @@ -638,10 +647,12 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun listFeatureEvaluations(): ListFeatureEvaluationsResponse = bearerClient().listFeatureEvaluations { it.userContext(codeWhispererUserContext()) + it.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendMetricDataTelemetry(eventName: String, metadata: Map): SendTelemetryEventResponse = @@ -656,6 +667,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendChatAddMessageTelemetry( @@ -694,6 +706,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendChatInteractWithMessageTelemetry( @@ -723,6 +736,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendChatUserModificationTelemetry( @@ -747,6 +761,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } override fun sendInlineChatTelemetry( @@ -782,6 +797,7 @@ class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispe } requestBuilder.optOutPreference(getTelemetryOptOutPreference()) requestBuilder.userContext(codeWhispererUserContext()) + requestBuilder.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } companion object { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt index f19df1595bf..6f3a84fcbf9 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt @@ -22,6 +22,8 @@ import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService import software.aws.toolkits.jetbrains.services.amazonq.calculateIfIamIdentityCenterConnection +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants import software.aws.toolkits.jetbrains.utils.notifyInfo @@ -48,6 +50,7 @@ private fun notifyInvalidSelectedCustomization(project: Project) { } private fun notifyNewCustomization(project: Project) { + if (ApplicationManager.getApplication().isUnitTestMode) return notifyInfo( title = message("codewhisperer.custom.dialog.title"), content = message("codewhisperer.notification.custom.new_customization"), @@ -81,6 +84,18 @@ class DefaultCodeWhispererModelConfigurator : CodeWhispererModelConfigurator, Pe private var customizationArnOverrideV2: String? = null + init { + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + pluginAwareExecuteOnPooledThread { + CodeWhispererModelConfigurator.getInstance().listCustomizations(project, passive = true) + } + } + } + ) + } override fun showConfigDialog(project: Project) { runInEdt { calculateIfIamIdentityCenterConnection(project) { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/QStatusBarLoggedInActionGroup.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/QStatusBarLoggedInActionGroup.kt index 4f7833a1fc0..3dd88d45d22 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/QStatusBarLoggedInActionGroup.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/QStatusBarLoggedInActionGroup.kt @@ -8,10 +8,15 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.project.Project import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.actions.SsoLogoutAction import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.isSono +import software.aws.toolkits.jetbrains.services.amazonq.actions.QSwitchProfilesAction +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererConnectOnGithubAction import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererLearnMoreAction import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererProvideFeedbackAction @@ -48,18 +53,11 @@ class QStatusBarLoggedInActionGroup : DefaultActionGroup() { } override fun getChildren(e: AnActionEvent?) = e?.project?.let { + val isPendingActiveProfile = QRegionProfileManager.getInstance().hasValidConnectionButNoActiveProfile(it) buildList { - add(Separator.create()) - add(Separator.create(message("codewhisperer.statusbar.sub_menu.inline.title"))) - addAll(buildActionListForInlineSuggestions(it, actionProvider)) - - add(Separator.create()) - add(Separator.create(message("codewhisperer.statusbar.sub_menu.security_scans.title"))) - addAll(buildActionListForCodeScan(it, actionProvider)) - - add(Separator.create()) - add(Separator.create(message("codewhisperer.statusbar.sub_menu.other_features.title"))) - addAll(buildActionListForOtherFeatures(it, actionProvider)) + if (!isPendingActiveProfile) { + addAll(buildActionListForActiveProfileSelected(it, actionProvider)) + } add(Separator.create()) add(Separator.create(message("codewhisperer.statusbar.sub_menu.connect_help.title"))) @@ -67,6 +65,10 @@ class QStatusBarLoggedInActionGroup : DefaultActionGroup() { add(Separator.create()) add(CodeWhispererShowSettingsAction()) + ( + ToolkitConnectionManager.getInstance(it).activeConnectionForFeature(QConnection.getInstance()) as? AwsBearerTokenConnection + )?.takeIf { !it.isSono() } + ?.let { add(QSwitchProfilesAction()) } ToolkitConnectionManager.getInstance(it).activeConnectionForFeature(CodeWhispererConnection.getInstance())?.let { c -> (c as? AwsBearerTokenConnection)?.let { connection -> add(SsoLogoutAction(connection)) @@ -74,4 +76,21 @@ class QStatusBarLoggedInActionGroup : DefaultActionGroup() { } }.toTypedArray() }.orEmpty() + + private fun buildActionListForActiveProfileSelected( + project: Project, + actionProvider: ActionProvider, + ): List = buildList { + add(Separator.create()) + add(Separator.create(message("codewhisperer.statusbar.sub_menu.inline.title"))) + addAll(buildActionListForInlineSuggestions(project, actionProvider)) + + add(Separator.create()) + add(Separator.create(message("codewhisperer.statusbar.sub_menu.security_scans.title"))) + addAll(buildActionListForCodeScan(project, actionProvider)) + + add(Separator.create()) + add(Separator.create(message("codewhisperer.statusbar.sub_menu.other_features.title"))) + addAll(buildActionListForOtherFeatures(project, actionProvider)) + } } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src-242+/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt similarity index 60% rename from plugins/amazonq/codewhisperer/jetbrains-community/src-242+/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt index 68e82ab604e..e62d0fb1a1a 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src-242+/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/InlineCompletionRemoteRendererFactory.kt @@ -1,5 +1,6 @@ -// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + package software.aws.toolkits.jetbrains.services.codewhisperer.inlay import com.intellij.codeInsight.inline.completion.render.InlineCompletionLineRenderer @@ -13,13 +14,24 @@ import com.intellij.xdebugger.ui.DebuggerColors ) object InlineCompletionRemoteRendererFactory { fun createLineInlay(editor: Editor, text: String): EditorCustomElementRenderer = - InlineCompletionLineRenderer(editor, text, editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE)) + InlineCompletionLineRenderer( + editor, + text, + editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE) + ) fun createBlockInlays(editor: Editor, block: List): List = block.map { InlineCompletionLineRenderer( editor, - listOf(InlineCompletionRenderTextBlock(it, editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE))) + listOf( + InlineCompletionRenderTextBlock( + it, + editor.colorsScheme.getAttributes( + DebuggerColors.INLINED_VALUES_EXECUTION_LINE + ) + ) + ) ) } } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerHandler.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerHandler.kt index fe73ff91e48..73b9f59be6f 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerHandler.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerHandler.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.editor.Editor import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo import software.aws.toolkits.telemetry.CodewhispererTriggerType @@ -17,6 +18,10 @@ interface CodeWhispererAutoTriggerHandler { automatedTriggerType: CodeWhispererAutomatedTriggerType, latencyContext: LatencyContext, ) { + val project = editor.project ?: return + if (QRegionProfileManager.getInstance().hasValidConnectionButNoActiveProfile(project)) { + return + } val triggerTypeInfo = TriggerTypeInfo(CodewhispererTriggerType.AutoTrigger, automatedTriggerType) LOG.debug { "autotriggering CodeWhisperer with type ${automatedTriggerType.telemetryType}" } 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 111ccdc509f..50a579fedd1 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 @@ -53,6 +53,10 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection import software.aws.toolkits.jetbrains.services.amazonq.SUPPLEMENTAL_CONTEXT_TIMEOUT +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.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManager @@ -91,6 +95,9 @@ import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.CodewhispererCompletionType import software.aws.toolkits.telemetry.CodewhispererSuggestionState 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 @@ -231,7 +238,9 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { buildCodeWhispererRequest( requestContext.fileContextInfo, requestContext.awaitSupplementalContext(), - requestContext.customizationArn + requestContext.customizationArn, + requestContext.profileArn, + requestContext.workspaceId, ) ) @@ -666,7 +675,44 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { // 5. customization val customizationArn = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn - return RequestContext(project, editor, triggerTypeInfo, caretPosition, fileContext, supplementalContext, connection, latencyContext, customizationArn) + val profileArn = QRegionProfileManager.getInstance().activeProfile(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, + supplementalContext, + connection, + latencyContext, + customizationArn, + profileArn, + workspaceId, + ) + } + + private fun getWorkspaceIds(project: Project): CompletableFuture { + val payload = GetConfigurationFromServerParams( + section = "aws.q.workspaceContext" + ) + return AmazonQLspService.executeIfRunning(project) { server -> + server.getConfigurationFromServer(payload) + } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) } fun validateResponse(response: GenerateCompletionsResponse): GenerateCompletionsResponse { @@ -800,6 +846,8 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { fileContextInfo: FileContextInfo, supplementalContext: SupplementalContextInfo?, customizationArn: String?, + profileArn: String?, + workspaceId: String?, ): GenerateCompletionsRequest { val programmingLanguage = ProgrammingLanguage.builder() .languageName(fileContextInfo.programmingLanguage.toCodeWhispererRuntimeLanguage().languageId) @@ -828,6 +876,8 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { .referenceTrackerConfiguration { it.recommendationsWithReferences(includeCodeWithReference) } .customizationArn(customizationArn) .optOutPreference(getTelemetryOptOutPreference()) + .profileArn(profileArn) + .workspaceId(workspaceId) .build() } } @@ -843,6 +893,8 @@ data class RequestContext( val connection: ToolkitConnection?, val latencyContext: LatencyContext, val customizationArn: String?, + val profileArn: String?, + val workspaceId: String?, ) { // TODO: should make the entire getRequestContext() suspend function instead of making supplemental context only var supplementalContext: SupplementalContextInfo? = null 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 c79ff7c0123..633a20ac0f9 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 @@ -53,6 +53,7 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection import software.aws.toolkits.jetbrains.services.amazonq.SUPPLEMENTAL_CONTEXT_TIMEOUT +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManagerNew @@ -243,7 +244,8 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { buildCodeWhispererRequest( requestContext.fileContextInfo, requestContext.awaitSupplementalContext(), - requestContext.customizationArn + requestContext.customizationArn, + requestContext.profileArn ) ) @@ -707,7 +709,9 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { // 5. customization val customizationArn = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn - return RequestContextNew(project, editor, triggerTypeInfo, caretPosition, fileContext, supplementalContext, connection, customizationArn) + val profileArn = QRegionProfileManager.getInstance().activeProfile(project)?.arn + + return RequestContextNew(project, editor, triggerTypeInfo, caretPosition, fileContext, supplementalContext, connection, customizationArn, profileArn) } fun validateResponse(response: GenerateCompletionsResponse): GenerateCompletionsResponse { @@ -825,6 +829,7 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { fileContextInfo: FileContextInfo, supplementalContext: SupplementalContextInfo?, customizationArn: String?, + profileArn: String?, ): GenerateCompletionsRequest { val programmingLanguage = ProgrammingLanguage.builder() .languageName(fileContextInfo.programmingLanguage.toCodeWhispererRuntimeLanguage().languageId) @@ -853,6 +858,7 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { .referenceTrackerConfiguration { it.recommendationsWithReferences(includeCodeWithReference) } .customizationArn(customizationArn) .optOutPreference(getTelemetryOptOutPreference()) + .profileArn(profileArn) .build() } } @@ -867,6 +873,7 @@ data class RequestContextNew( private val supplementalContextDeferred: Deferred, val connection: ToolkitConnection?, val customizationArn: String?, + val profileArn: String?, ) { // TODO: should make the entire getRequestContext() suspend function instead of making supplemental context only var supplementalContext: SupplementalContextInfo? = null diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt index 9589d8c0a96..77f901950c7 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt @@ -5,13 +5,16 @@ package software.aws.toolkits.jetbrains.services.codewhisperer.settings import com.intellij.icons.AllIcons import com.intellij.ide.DataManager +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.options.BoundConfigurable import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.options.ex.Settings import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.emptyText import com.intellij.ui.components.ActionLink import com.intellij.ui.components.fields.ExpandableTextField +import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.bindIntText import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText @@ -24,6 +27,7 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWh import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.settings.LspSettings import software.aws.toolkits.resources.message import java.awt.Font import java.util.concurrent.TimeUnit @@ -61,6 +65,24 @@ class CodeWhispererConfigurable(private val project: Project) : } } + group(message("amazonqFeatureDev.placeholder.lsp")) { + row(message("amazonqFeatureDev.placeholder.select_lsp_artifact")) { + val fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFileDescriptor() + fileChooserDescriptor.isForcedToUseIdeaFileChooser = true + + textFieldWithBrowseButton(fileChooserDescriptor = fileChooserDescriptor) + .bindText( + { LspSettings.getInstance().getArtifactPath().orEmpty() }, + { LspSettings.getInstance().setArtifactPath(it) } + ) + .applyToComponent { + emptyText.text = message("executableCommon.auto_managed") + } + .resizableColumn() + .align(Align.FILL) + } + } + group(message("aws.settings.codewhisperer.group.general")) { row { checkBox(message("aws.settings.codewhisperer.include_code_with_reference")).apply { @@ -116,6 +138,20 @@ class CodeWhispererConfigurable(private val project: Project) : } group(message("aws.settings.codewhisperer.group.q_chat")) { + row { + checkBox(message("aws.settings.codewhisperer.workspace_context")).apply { + connect.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + enabled(isCodeWhispererEnabled(project)) + } + } + ) + enabled(invoke) + bindSelected(codeWhispererSettings::isWorkspaceContextEnabled, codeWhispererSettings::toggleWorkspaceContextEnabled) + }.comment(message("aws.settings.codewhisperer.workspace_context.tooltip")) + }.visible(false) row { checkBox(message("aws.settings.codewhisperer.project_context")).apply { connect.subscribe( @@ -133,7 +169,7 @@ class CodeWhispererConfigurable(private val project: Project) : row(message("aws.settings.codewhisperer.project_context_index_thread")) { intTextField( - range = IntRange(0, 50) + range = CodeWhispererSettings.CONTEXT_INDEX_THREADS ).bindIntText(codeWhispererSettings::getProjectContextIndexThreadCount, codeWhispererSettings::setProjectContextIndexThreadCount) .apply { connect.subscribe( @@ -150,7 +186,7 @@ class CodeWhispererConfigurable(private val project: Project) : row(message("aws.settings.codewhisperer.project_context_index_max_size")) { intTextField( - range = IntRange(1, 4096) + range = CodeWhispererSettings.CONTEXT_INDEX_SIZE ).bindIntText(codeWhispererSettings::getProjectContextIndexMaxSize, codeWhispererSettings::setProjectContextIndexMaxSize) .apply { connect.subscribe( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidget.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidget.kt index 2ee5f8be678..529e20d3a8e 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidget.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidget.kt @@ -22,6 +22,9 @@ import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileWatcher import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService import software.aws.toolkits.jetbrains.services.amazonq.gettingstarted.QActionGroups.Q_SIGNED_OUT_ACTION_GROUP +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomizationListener import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.QStatusBarLoggedInActionGroup @@ -76,6 +79,20 @@ class CodeWhispererStatusBarWidget(project: Project) : } } ) + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected( + project: Project, + profile: QRegionProfile?, + ) { + if (project == this@CodeWhispererStatusBarWidget.project) { + statusBar.updateWidget(ID) + } + } + } + ) } override fun ID(): String = ID @@ -121,7 +138,7 @@ class CodeWhispererStatusBarWidget(project: Project) : } override fun getIcon(): Icon = - if (isQExpired(project)) { + if (isQExpired(project) || QRegionProfileManager.getInstance().isPendingProfileSelection(project)) { AllIcons.General.BalloonWarning } else if (!isQConnected(project)) { AllIcons.RunConfigurations.TestState.Run diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererConstants.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererConstants.kt index 1e36ceddc5d..9e328c54b81 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererConstants.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererConstants.kt @@ -6,7 +6,6 @@ package software.aws.toolkits.jetbrains.services.codewhisperer.util import com.intellij.openapi.actionSystem.DataKey import com.intellij.openapi.editor.markup.EffectType import com.intellij.openapi.editor.markup.TextAttributes -import com.intellij.openapi.util.registry.Registry import com.intellij.ui.JBColor import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.codewhispererruntime.model.AccessDeniedException @@ -154,12 +153,8 @@ object CodeWhispererConstants { } object Config { - val CODEWHISPERER_ENDPOINT - get() = System.getenv("__CODEWHISPERER_ENDPOINT") ?: Registry.get("amazon.q.endpoint").asString() - const val CODEWHISPERER_IDPOOL_ID = "us-east-1:70717e99-906f-4add-908c-bd9074a2f5b9" val Sigv4ClientRegion = Region.US_EAST_1 - val BearerClientRegion = Region.US_EAST_1 } object Customization { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererEndpointCustomizer.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererEndpointCustomizer.kt index 004181a6ec5..8d74ecca7e0 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererEndpointCustomizer.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererEndpointCustomizer.kt @@ -17,9 +17,12 @@ import software.amazon.awssdk.core.retry.RetryPolicy import software.amazon.awssdk.http.SdkHttpRequest import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient import software.amazon.awssdk.http.nio.netty.ProxyConfiguration +import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClientBuilder import software.amazon.awssdk.services.codewhispererstreaming.CodeWhispererStreamingAsyncClientBuilder import software.aws.toolkits.core.ToolkitClientCustomizer +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.services.amazonq.profile.QEndpoints import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings import java.net.Proxy import java.net.URI @@ -37,10 +40,12 @@ class CodeWhispererEndpointCustomizer : ToolkitClientCustomizer { clientOverrideConfiguration: ClientOverrideConfiguration.Builder, ) { if (builder is CodeWhispererRuntimeClientBuilder || builder is CodeWhispererStreamingAsyncClientBuilder) { - val endpoint = URI.create(CodeWhispererConstants.Config.CODEWHISPERER_ENDPOINT) + val endpoint = tryOrNull { QEndpoints.getQEndpointWithRegion(regionId) } + ?.let { URI.create(it) } + ?: URI.create(QEndpoints.Q_DEFAULT_SERVICE_CONFIG.ENDPOINT) builder .endpointOverride(endpoint) - .region(CodeWhispererConstants.Config.BearerClientRegion) + .region(Region.of(regionId)) clientOverrideConfiguration.retryPolicy(RetryPolicy.none()) clientOverrideConfiguration.addExecutionInterceptor( object : ExecutionInterceptor { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererZipUploadManager.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererZipUploadManager.kt index 8d997645a14..f964092a8d5 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererZipUploadManager.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererZipUploadManager.kt @@ -22,6 +22,7 @@ import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.core.AwsClientManager import software.aws.toolkits.jetbrains.services.amazonq.RetryableOperation +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanServerException import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanSession.Companion.APPLICATION_ZIP import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanSession.Companion.AWS_KMS @@ -202,6 +203,7 @@ class CodeWhispererZipUploadManager(private val project: Project) { UploadContext.fromCodeAnalysisUploadContext(CodeAnalysisUploadContext.builder().codeScanName(taskName).build()) } ) + .profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) .build() ) }, diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt index a82f8700eb4..bf3db6cdf29 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt @@ -176,7 +176,9 @@ internal class CodeWhispererCodeCoverageTrackerTestPython : CodeWhispererCodeCov }, null, mock(), - aString() + aString(), + aString(), + aString(), ) val responseContext = ResponseContext("sessionId") val recommendationContext = RecommendationContext( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt index 5224284b42a..67aff7a14e3 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt @@ -39,17 +39,18 @@ class CodeWhispererConfigurableTest : CodeWhispererTestBase() { val checkboxes = panel.components.filterIsInstance() - assertThat(checkboxes.size).isEqualTo(5) + assertThat(checkboxes.size).isEqualTo(6) assertThat(checkboxes.map { it.text }).containsExactlyInAnyOrder( message("aws.settings.codewhisperer.include_code_with_reference"), message("aws.settings.codewhisperer.configurable.opt_out.title"), message("aws.settings.codewhisperer.automatic_import_adder"), + "Workspace context", message("aws.settings.codewhisperer.project_context"), message("aws.settings.codewhisperer.project_context_gpu") ) val comments = panel.components.filterIsInstance() - assertThat(comments.size).isEqualTo(8) + assertThat(comments.size).isEqualTo(9) mockCodeWhispererEnabledStatus(false) ApplicationManager.getApplication().messageBus.syncPublisher(ToolkitConnectionManagerListener.TOPIC) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererFeatureConfigServiceTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererFeatureConfigServiceTest.kt index 56f6f66b517..378e2703756 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererFeatureConfigServiceTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererFeatureConfigServiceTest.kt @@ -3,6 +3,7 @@ package software.aws.toolkits.jetbrains.services.codewhisperer +import com.intellij.openapi.project.Project import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.ProjectRule @@ -34,6 +35,7 @@ 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.SONO_URL import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import kotlin.reflect.full.memberFunctions import kotlin.test.Test @@ -62,7 +64,7 @@ class CodeWhispererFeatureConfigServiceTest { @Test fun `test highlightCommand returns non-empty`() { - mockClientManagerRule.create().stub { + val mockClient = mockClientManagerRule.create().stub { on { listFeatureEvaluations(any()) } doReturn ListFeatureEvaluationsResponse.builder().featureEvaluations( listOf( FeatureEvaluation.builder() @@ -74,9 +76,16 @@ class CodeWhispererFeatureConfigServiceTest { ).build() } + projectRule.project.replaceService( + QRegionProfileManager::class.java, + mock { on { getQClient(any(), eq(CodeWhispererRuntimeClient::class)) } doReturn mockClient }, + disposableRule.disposable + ) + val mockTokenSettings = mock { on { providerId } doReturn "mock" on { region } doReturn AwsRegion.GLOBAL + on { withRegion(any()) } doReturn this.mock } val mockSsoConnection = mock { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererModelConfiguratorTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererModelConfiguratorTest.kt index b1fa0ef5592..751db478b5b 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererModelConfiguratorTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererModelConfiguratorTest.kt @@ -7,8 +7,10 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.registerServiceInstance import com.intellij.testFramework.replaceService import com.intellij.util.xmlb.XmlSerializer +import migration.software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator import org.assertj.core.api.Assertions.assertThat import org.jdom.output.XMLOutputter import org.junit.Before @@ -40,10 +42,14 @@ import software.aws.toolkits.jetbrains.core.credentials.sono.isSono import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService import software.aws.toolkits.jetbrains.services.amazonq.FeatureContext +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener +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.CodeWhispererCustomizationState import software.aws.toolkits.jetbrains.services.codewhisperer.customization.DefaultCodeWhispererModelConfigurator import software.aws.toolkits.jetbrains.utils.xmlElement +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.reflect.full.memberProperties import kotlin.reflect.jvm.isAccessible @@ -75,6 +81,7 @@ class CodeWhispererModelConfiguratorTest { private lateinit var sut: DefaultCodeWhispererModelConfigurator private lateinit var mockClient: CodeWhispererRuntimeClient private lateinit var abManager: CodeWhispererFeatureConfigService + private lateinit var mockClintAdaptor: CodeWhispererClientAdaptor @Before fun setup() { @@ -83,7 +90,11 @@ class CodeWhispererModelConfiguratorTest { regionProvider.addRegion(Region.US_EAST_1) regionProvider.addRegion(Region.US_EAST_2) - sut = DefaultCodeWhispererModelConfigurator() + sut = spy(CodeWhispererModelConfigurator.getInstance() as DefaultCodeWhispererModelConfigurator).also { spyInstance -> + ApplicationManager.getApplication().replaceService( + DefaultCodeWhispererModelConfigurator::class.java, spyInstance, disposableRule.disposable + ) + } (ToolkitConnectionManager.getInstance(projectRule.project) as DefaultToolkitConnectionManager).loadState(ToolkitConnectionManagerState()) mockClient.stub { @@ -110,6 +121,9 @@ class CodeWhispererModelConfiguratorTest { abManager, disposableRule.disposable ) + + mockClintAdaptor = mock() + projectRule.project.registerServiceInstance(CodeWhispererClientAdaptor::class.java, mockClintAdaptor) } @Test @@ -550,4 +564,48 @@ class CodeWhispererModelConfiguratorTest { assertThat(actual.previousAvailableCustomizations).hasSize(1) assertThat(actual.previousAvailableCustomizations["fake-sso-url"]).isEqualTo(listOf("arn_1", "arn_2", "arn_3")) } + + @Test + fun `profile switch should keep using existing customization if new list still contains that arn`() { + val ssoConn = spy(LegacyManagedBearerSsoConnection(region = "us-east-1", startUrl = "url 1", scopes = Q_SCOPES)) + ToolkitConnectionManager.getInstance(projectRule.project).switchConnection(ssoConn) + val oldCustomization = CodeWhispererCustomization("oldArn", "oldName", "oldDescription") + sut.switchCustomization(projectRule.project, oldCustomization) + + assertThat(sut.activeCustomization(projectRule.project)).isEqualTo(oldCustomization) + + val fakeCustomizations = listOf( + CodeWhispererCustomization("oldArn", "oldName", "oldDescription") + ) + mockClintAdaptor.stub { on { listAvailableCustomizations() } doReturn fakeCustomizations } + + ApplicationManager.getApplication().messageBus + .syncPublisher(QRegionProfileSelectedListener.TOPIC) + .onProfileSelected(projectRule.project, null) + + assertThat(sut.activeCustomization(projectRule.project)).isEqualTo(oldCustomization) + } + + @Test + fun `profile switch should invalidate obsolete customization if it's not in the new list`() { + val ssoConn = spy(LegacyManagedBearerSsoConnection(region = "us-east-1", startUrl = "url 1", scopes = Q_SCOPES)) + ToolkitConnectionManager.getInstance(projectRule.project).switchConnection(ssoConn) + val oldCustomization = CodeWhispererCustomization("oldArn", "oldName", "oldDescription") + sut.switchCustomization(projectRule.project, oldCustomization) + assertThat(sut.activeCustomization(projectRule.project)).isEqualTo(oldCustomization) + val fakeCustomizations = listOf( + CodeWhispererCustomization("newArn", "newName", "newDescription") + ) + mockClintAdaptor.stub { on { listAvailableCustomizations() } doReturn fakeCustomizations } + + val latch = CountDownLatch(1) + + ApplicationManager.getApplication().messageBus + .syncPublisher(QRegionProfileSelectedListener.TOPIC) + .onProfileSelected(projectRule.project, null) + + latch.await(2, TimeUnit.SECONDS) + + assertThat(sut.activeCustomization(projectRule.project)).isNull() + } } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt index 1d5646d3869..4ac3f45eddd 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt @@ -212,7 +212,9 @@ class CodeWhispererServiceTest { supplementalContextDeferred = async { mockSupContext }, connection = ToolkitConnectionManager.getInstance(projectRule.project).activeConnection(), latencyContext = LatencyContext(), - customizationArn = "fake-arn" + customizationArn = "fake-arn", + profileArn = "fake-arn", + workspaceId = null, ) ) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt index 94474eefa6a..84263c62ad2 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt @@ -13,10 +13,14 @@ import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetsManager import com.intellij.testFramework.replaceService import com.intellij.testFramework.runInEdtAndWait import com.intellij.util.xmlb.XmlSerializer +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject import org.assertj.core.api.Assertions.assertThat import org.jdom.output.XMLOutputter import org.junit.Before import org.junit.Ignore +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.never @@ -24,6 +28,7 @@ import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import software.aws.toolkits.jetbrains.core.ToolWindowHeadlessManagerImpl +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExploreActionState import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled @@ -40,6 +45,9 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() { private lateinit var codewhispererServiceSpy: CodeWhispererService private lateinit var toolWindowHeadlessManager: ToolWindowHeadlessManagerImpl + @get:Rule + val mockkRule = MockKRule(this) + @Before override fun setUp() { super.setUp() @@ -211,6 +219,53 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() { assertThat(actual.autoBuildSetting).hasSize(1) assertThat(actual.autoBuildSetting["project1"]).isTrue() } + + @Test + fun `context thread count is returned in range`() { + val sut = CodeWhispererSettings.getInstance() + + mapOf( + 1 to 1, + 0 to 0, + -1 to 0, + 123 to 50, + 50 to 50, + 51 to 50, + ).forEach { s, expected -> + sut.setProjectContextIndexThreadCount(s) + assertThat(sut.getProjectContextIndexThreadCount()).isEqualTo(expected) + } + } + + @Test + fun `context index size is returned in range`() { + val sut = CodeWhispererSettings.getInstance() + + mapOf( + 1 to 1, + 0 to 1, + -1 to 1, + 123 to 123, + 2047 to 2047, + 4096 to 4096, + 4097 to 4096, + ).forEach { s, expected -> + sut.setProjectContextIndexMaxSize(s) + assertThat(sut.getProjectContextIndexMaxSize()).isEqualTo(expected) + } + } + + @Test + fun `toggleMetricOptIn should trigger LSP didChangeConfiguration`() { + mockkObject(AmazonQLspService) + every { AmazonQLspService.didChangeConfiguration(any()) } returns Unit + settingsManager.toggleMetricOptIn(true) + settingsManager.toggleMetricOptIn(false) + + io.mockk.verify(atLeast = 2) { + AmazonQLspService.didChangeConfiguration(any()) + } + } } class CodeWhispererSettingUnitTest { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt index a3e97f60c38..e000bbaeafc 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt @@ -37,6 +37,7 @@ import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRul import software.aws.toolkits.jetbrains.core.credentials.MockToolkitAuthManagerRule import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.codeWhispererRecommendationActionId import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonFileName import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonResponse @@ -88,6 +89,7 @@ open class CodeWhispererTestBase { protected lateinit var settingsManager: CodeWhispererSettings private lateinit var originalExplorerActionState: CodeWhispererExploreActionState private lateinit var originalSettings: CodeWhispererConfiguration + private lateinit var qRegionProfileManagerSpy: QRegionProfileManager @Before open fun setUp() { @@ -169,6 +171,16 @@ open class CodeWhispererTestBase { val conn = authManagerRule.createConnection(ManagedSsoProfile("us-east-1", "url", Q_SCOPES)) ToolkitConnectionManager.getInstance(projectRule.project).switchConnection(conn) + + qRegionProfileManagerSpy = spy(QRegionProfileManager.getInstance()) + qRegionProfileManagerSpy.stub { + onGeneric { + hasValidConnectionButNoActiveProfile(any()) + } doAnswer { + false + } + } + ApplicationManager.getApplication().replaceService(QRegionProfileManager::class.java, qRegionProfileManagerSpy, disposableRule.disposable) } @After diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/QEndpointsTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/QEndpointsTest.kt new file mode 100644 index 00000000000..057c61030f7 --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/QEndpointsTest.kt @@ -0,0 +1,41 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer + +import com.intellij.testFramework.ApplicationExtension +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import software.aws.toolkits.jetbrains.services.amazonq.profile.QEndpoints +import software.aws.toolkits.jetbrains.utils.rules.RegistryExtension + +@ExtendWith(ApplicationExtension::class) +class QEndpointsTest : BasePlatformTestCase() { + + @JvmField + @RegisterExtension + val registryExtension = RegistryExtension() + + @Test + fun `test default registry value and parse`() { + val testJson = """ + [ + {"region": "us-east-1", "endpoint": "https://codewhisperer.us-east-1.amazonaws.com/"}, + {"region": "eu-central-1", "endpoint": "https://rts.prod-eu-central-1.codewhisperer.ai.aws.dev/"} + ] + """.trimIndent() + + registryExtension.setValue("amazon.q.endpoints.json", testJson) + + val parsed = QEndpoints.listRegionEndpoints() + assertEquals(2, parsed.size) + + val iad = parsed.first { it.region == "us-east-1" } + assertEquals("https://codewhisperer.us-east-1.amazonaws.com/", iad.endpoint) + + val fra = parsed.first { it.region == "eu-central-1" } + assertEquals("https://rts.prod-eu-central-1.codewhisperer.ai.aws.dev/", fra.endpoint) + } +} diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/QRegionProfileManagerTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/QRegionProfileManagerTest.kt new file mode 100644 index 00000000000..628ab8e36c9 --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/QRegionProfileManagerTest.kt @@ -0,0 +1,419 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.DisposableRule +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.replaceService +import com.intellij.util.xmlb.XmlSerializer +import org.assertj.core.api.Assertions.assertThat +import org.jdom.output.XMLOutputter +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.stub +import org.mockito.kotlin.whenever +import software.amazon.awssdk.core.pagination.sync.SdkIterable +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClient +import software.amazon.awssdk.services.codewhispererruntime.model.ListAvailableProfilesRequest +import software.amazon.awssdk.services.codewhispererruntime.model.Profile +import software.amazon.awssdk.services.codewhispererruntime.paginators.ListAvailableProfilesIterable +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.MockClientManagerRule +import software.aws.toolkits.jetbrains.core.MockResourceCacheRule +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ManagedSsoProfile +import software.aws.toolkits.jetbrains.core.credentials.MockToolkitAuthManagerRule +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.logoutFromSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule +import software.aws.toolkits.jetbrains.services.amazonq.profile.QEndpoints +import software.aws.toolkits.jetbrains.services.amazonq.profile.QProfileResources +import software.aws.toolkits.jetbrains.services.amazonq.profile.QProfileState +import software.aws.toolkits.jetbrains.services.amazonq.profile.QProfileSwitchIntent +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import software.aws.toolkits.jetbrains.utils.xmlElement +import java.net.URI +import java.util.function.Consumer +import kotlin.test.fail + +// TODO: should use junit5 +class QRegionProfileManagerTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val authRule = MockToolkitAuthManagerRule() + + @JvmField + @Rule + val clientRule = MockClientManagerRule() + + @Rule + @JvmField + val regionProviderRule = MockRegionProviderRule() + + @JvmField + @Rule + val disposableRule = DisposableRule() + + @get:Rule + val resourceCache = MockResourceCacheRule() + + private lateinit var sut: QRegionProfileManager + private val project: Project + get() = projectRule.project + + @Before + fun setup() { + clientRule.create() + regionProviderRule.addRegion(AwsRegion("us-east-1", "US East (N. Virginia)", "aws")) + regionProviderRule.addRegion(AwsRegion("eu-central-1", "Europe (Frankfurt)", "aws")) + sut = QRegionProfileManager() + val conn = authRule.createConnection(ManagedSsoProfile(ssoRegion = "us-east-1", startUrl = "", scopes = Q_SCOPES)) + ToolkitConnectionManager.getInstance(project).switchConnection(conn) + val realManager = ToolkitConnectionManager.getInstance(project) + val managerSpy = spy(realManager) + doReturn(BearerTokenAuthState.AUTHORIZED).whenever(managerSpy).connectionStateForFeature(QConnection.getInstance()) + project.replaceService(ToolkitConnectionManager::class.java, managerSpy, disposableRule.disposable) + } + + @Test + fun `switchProfile should switch the current connection(project) to the selected profile`() { + sut.switchProfile(project, QRegionProfile(arn = "arn", profileName = "foo_profile"), QProfileSwitchIntent.User) + assertThat(sut.activeProfile(project)).isEqualTo(QRegionProfile(arn = "arn", profileName = "foo_profile")) + + sut.switchProfile(project, QRegionProfile(arn = "another_arn", profileName = "bar_profile"), QProfileSwitchIntent.User) + assertThat(sut.activeProfile(project)).isEqualTo(QRegionProfile(arn = "another_arn", profileName = "bar_profile")) + } + + @Test + fun `switchProfile should return null if user is not connected`() { + sut.switchProfile(project, QRegionProfile(arn = "arn", profileName = "foo_profile"), QProfileSwitchIntent.User) + assertThat(sut.activeProfile(project)).isEqualTo(QRegionProfile(arn = "arn", profileName = "foo_profile")) + + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance())?.let { + if (it is AwsBearerTokenConnection) { + logoutFromSsoConnection(project, it) + } + } + ToolkitConnectionManager.getInstance(project).switchConnection(null) + assertThat(ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance())).isNull() + assertThat(sut.activeProfile(project)).isNull() + } + + @Test + fun `data is cleared when user logs out`() { + sut.switchProfile(project, QRegionProfile(arn = "arn", profileName = "foo_profile"), QProfileSwitchIntent.User) + assertThat(sut.activeProfile(project)).isEqualTo(QRegionProfile(arn = "arn", profileName = "foo_profile")) + + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance())?.let { + if (it is AwsBearerTokenConnection) { + logoutFromSsoConnection(project, it) + } + } + + assertThat(sut.state).satisfiesKt { + assertThat(it.connectionIdToActiveProfile).isEmpty() + assertThat(it.connectionIdToProfileList).isEmpty() + } + } + + @Test + fun `switch should send message onProfileChanged for active switch`() { + var cnt = 0 + project.messageBus.connect(disposableRule.disposable).subscribe( + QRegionProfileSelectedListener.TOPIC, + object : QRegionProfileSelectedListener { + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + cnt += 1 + } + } + ) + + assertThat(cnt).isEqualTo(0) + sut.switchProfile(project, QRegionProfile(arn = "arn", profileName = "foo_profile"), QProfileSwitchIntent.Reload) + assertThat(cnt).isEqualTo(1) + sut.switchProfile(project, QRegionProfile(arn = "another_arn", profileName = "BAR_PROFILE"), QProfileSwitchIntent.Reload) + assertThat(cnt).isEqualTo(2) + } + + @Test + fun `listProfiles will call each client to get profiles`() { + val client = clientRule.create() + val mockResponse: SdkIterable = SdkIterable { + listOf( + Profile.builder().profileName("FOO").arn("foo").build(), + ).toMutableList().iterator() + } + + val mockResponse2: SdkIterable = SdkIterable { + listOf( + Profile.builder().profileName("BAR").arn("bar").build(), + ).toMutableList().iterator() + } + + val iterable: ListAvailableProfilesIterable = mock { + on { it.profiles() } doReturn mockResponse doReturn mockResponse2 + } + + // TODO: not sure if we can mock client with different region different response? + client.stub { + onGeneric { listAvailableProfilesPaginator(any>()) } doReturn iterable + } + val connectionSettings = sut.getQClientSettings(project) + resourceCache.addEntry(connectionSettings, QProfileResources.LIST_REGION_PROFILES, QProfileResources.LIST_REGION_PROFILES.fetch(connectionSettings)) + + assertThat(sut.listRegionProfiles(project)) + .hasSize(2) + .containsExactlyInAnyOrder( + QRegionProfile("FOO", "foo"), + QRegionProfile("BAR", "bar") + ) + } + + @Test + fun `validateProfile should cross validate selected profile with latest API response for current project and remove it if its not longer accessible`() { + val client = clientRule.create() + val mockResponse: SdkIterable = SdkIterable { + listOf( + Profile.builder().profileName("foo").arn("foo-arn-v2").build(), + Profile.builder().profileName("bar").arn("bar-arn").build(), + ).toMutableList().iterator() + } + val iterable: ListAvailableProfilesIterable = mock { + on { it.profiles() } doReturn mockResponse + } + client.stub { + onGeneric { listAvailableProfilesPaginator(any>()) } doReturn iterable + } + + val activeConn = + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) ?: fail("connection shouldn't be null") + val anotherConn = authRule.createConnection(ManagedSsoProfile(ssoRegion = "us-east-1", startUrl = "anotherUrl", scopes = Q_SCOPES)) + val fooProfile = QRegionProfile("foo", "foo-arn") + val barProfile = QRegionProfile("bar", "bar-arn") + val state = QProfileState().apply { + this.connectionIdToActiveProfile[activeConn.id] = fooProfile + this.connectionIdToActiveProfile[anotherConn.id] = barProfile + } + sut.loadState(state) + assertThat(sut.activeProfile(project)).isEqualTo(fooProfile) + + sut.validateProfile(project) + assertThat(sut.activeProfile(project)).isNull() + assertThat(sut.state.connectionIdToActiveProfile).isEqualTo(mapOf(anotherConn.id to barProfile)) + } + + @Test + fun `clientSettings should return the region Q profile specify`() { + MockClientManager.useRealImplementations(disposableRule.disposable) + sut.switchProfile( + project, + QRegionProfile(arn = "arn:aws:codewhisperer:eu-central-1:123456789012:profile/FOO_PROFILE", profileName = "FOO_PROFILE"), + QProfileSwitchIntent.User + ) + assertThat( + sut.activeProfile(project) + ).isEqualTo(QRegionProfile(arn = "arn:aws:codewhisperer:eu-central-1:123456789012:profile/FOO_PROFILE", profileName = "FOO_PROFILE")) + + val settings = sut.getQClientSettings(project) + assertThat(settings.region.id).isEqualTo(Region.EU_CENTRAL_1.id()) + + sut.switchProfile( + project, + QRegionProfile(arn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/BAR_PROFILE", profileName = "BAR_PROFILE"), + QProfileSwitchIntent.User + ) + assertThat( + sut.activeProfile(project) + ).isEqualTo(QRegionProfile(arn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/BAR_PROFILE", profileName = "BAR_PROFILE")) + + val settings2 = sut.getQClientSettings(project) + assertThat(settings2.region.id).isEqualTo(Region.US_EAST_1.id()) + } + + @Test + fun `getClient should return correct client with region and endpoint`() { + MockClientManager.useRealImplementations(disposableRule.disposable) + + sut.switchProfile( + project, + QRegionProfile(arn = "arn:aws:codewhisperer:eu-central-1:123456789012:profile/FOO_PROFILE", profileName = "FOO_PROFILE"), + QProfileSwitchIntent.User + ) + assertThat( + sut.activeProfile(project) + ).isEqualTo(QRegionProfile(arn = "arn:aws:codewhisperer:eu-central-1:123456789012:profile/FOO_PROFILE", profileName = "FOO_PROFILE")) + assertThat(sut.getQClientSettings(project).region.id).isEqualTo(Region.EU_CENTRAL_1.id()) + + val client = sut.getQClient(project) + assertThat(client).isInstanceOf(CodeWhispererRuntimeClient::class.java) + assertThat(client.serviceClientConfiguration().region()).isEqualTo(Region.EU_CENTRAL_1) + assertThat( + client.serviceClientConfiguration().endpointOverride().get() + ).isEqualTo(URI.create(QEndpoints.getQEndpointWithRegion(Region.EU_CENTRAL_1.id()))) + + sut.switchProfile( + project, + QRegionProfile(arn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/BAR_PROFILE", profileName = "BAR_PROFILE"), + QProfileSwitchIntent.User + ) + assertThat( + sut.activeProfile(project) + ).isEqualTo(QRegionProfile(arn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/BAR_PROFILE", profileName = "BAR_PROFILE")) + assertThat(sut.getQClientSettings(project).region.id).isEqualTo(Region.US_EAST_1.id()) + + val client2 = sut.getQClient(project) + assertThat(client2).isInstanceOf(CodeWhispererRuntimeClient::class.java) + assertThat(client2.serviceClientConfiguration().region()).isEqualTo(Region.US_EAST_1) + assertThat( + client2.serviceClientConfiguration().endpointOverride().get() + ).isEqualTo(URI.create(QEndpoints.getQEndpointWithRegion(Region.US_EAST_1.id()))) + } + + @Test + fun `deserialize empty data`() { + val element = xmlElement( + """ + + + """ + ) + val actual = XmlSerializer.deserialize(element, QProfileState::class.java) + assertThat(actual.connectionIdToActiveProfile).hasSize(0) + assertThat(actual.connectionIdToProfileList).hasSize(0) + } + + @Test + fun `serialize with data`() { + val element = xmlElement( + """ + + + """.trimIndent() + ) + + val state = QProfileState().apply { + this.connectionIdToActiveProfile.putAll( + mapOf( + "conn-123" to QRegionProfile( + profileName = "myActiveProfile", arn = "arn:aws:codewhisperer:us-west-2:123456789012:profile/myActiveProfile" + ) + ) + ) + + connectionIdToProfileList.putAll( + mapOf("conn-123" to 2) + ) + } + + XmlSerializer.serializeInto(state, element) + val actualXmlString = XMLOutputter().outputString(element) + val expectedXmlString = + "\n" + + "" + + "" + + "" + + assertThat(actualXmlString).isEqualTo(expectedXmlString) + } + + @Test + fun `deserialize with data`() { + val element = xmlElement( + """ + + + + + """.trimIndent() + ) + + val actualState = XmlSerializer.deserialize(element, QProfileState::class.java) + + assertThat(actualState.connectionIdToActiveProfile).hasSize(1) + val activeProfile = actualState.connectionIdToActiveProfile["conn-123"] + assertThat(activeProfile).isEqualTo( + QRegionProfile( + profileName = "myActiveProfile", + arn = "arn:aws:codewhisperer:us-west-2:123456789012:profile/myActiveProfile" + ) + ) + + assertThat(actualState.connectionIdToProfileList).hasSize(1) + val profileList = actualState.connectionIdToProfileList["conn-123"] + assertThat(profileList).isEqualTo(2) + } + + @Test + fun `getIdcConnectionOrNull handles NOT_AUTH and AUTHORIZED correctly`() { + val managerSpy = ToolkitConnectionManager.getInstance(project) + doReturn(BearerTokenAuthState.NOT_AUTHENTICATED).whenever(managerSpy) + .connectionStateForFeature(QConnection.getInstance()) + + // NOT AUTHORIZED + val notAuthConn = sut.getIdcConnectionOrNull(project) + assertThat(notAuthConn).isNull() + + doReturn(BearerTokenAuthState.AUTHORIZED) + .whenever(managerSpy).connectionStateForFeature(QConnection.getInstance()) + + // AUTHORIZED + val normalConn = authRule.createConnection( + ManagedSsoProfile(ssoRegion = "us-east-1", startUrl = "", scopes = Q_SCOPES) + ) + managerSpy.switchConnection(normalConn) + + val normalConnectionResult = sut.getIdcConnectionOrNull(project) + assertThat(normalConnectionResult).isNotNull() + assertThat(normalConnectionResult).isEqualTo(normalConn) + } +} diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt index 330ce9a092e..1178a7ff967 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt @@ -257,7 +257,9 @@ fun aRequestContext( Random.nextLong(), aString() ), - customizationArn = null + customizationArn = null, + profileArn = null, + workspaceId = null, ) } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts index 1be8f151a72..1d033f1cc65 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts @@ -42,6 +42,7 @@ export const createMynahUI = ( codeScanEnabled: boolean, codeTestEnabled: boolean, highlightCommand?: QuickActionCommand, + profileName?: string ) => { let disclaimerCardActive = !disclaimerAcknowledged @@ -88,7 +89,8 @@ export const createMynahUI = ( isDocEnabled, isCodeScanEnabled, isCodeTestEnabled, - highlightCommand + highlightCommand, + profileName }) // eslint-disable-next-line prefer-const diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts index 63682d596e7..f94eb193c98 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemType, MynahUIDataModel, QuickActionCommandGroup, QuickActionCommand } from '@aws/mynah-ui-chat' +import {ChatItemType, MynahUIDataModel, QuickActionCommandGroup, QuickActionCommand, ChatItem} from '@aws/mynah-ui-chat' import { TabType } from '../storages/tabsStorage' import { FollowUpGenerator } from '../followUps/generator' import { QuickActionGenerator } from '../quickActions/generator' @@ -16,12 +16,14 @@ export interface TabDataGeneratorProps { isCodeScanEnabled: boolean isCodeTestEnabled: boolean highlightCommand?: QuickActionCommand + profileName?: string } export class TabDataGenerator { private followUpsGenerator: FollowUpGenerator public quickActionsGenerator: QuickActionGenerator public highlightCommand?: QuickActionCommand + profileName?: string private tabTitle: Map = new Map([ ['unknown', 'Chat'], @@ -91,6 +93,20 @@ What would you like to work on?`, isCodeTestEnabled: props.isCodeTestEnabled, }) this.highlightCommand = props.highlightCommand + this.profileName = props.profileName + } + + private get regionProfileCard(): ChatItem | undefined { + console.log('[DEBUG] Received profileName:', this.profileName) + if (!this.profileName) { + return undefined + } + return { + type: ChatItemType.ANSWER, + body: `You are using the ${this.profileName} profile for this chat period`, + status: 'info', + messageId: 'regionProfile', + } } public getTabData(tabType: TabType, needWelcomeMessages: boolean, taskName?: string): MynahUIDataModel { @@ -103,7 +119,8 @@ What would you like to work on?`, contextCommands: this.getContextCommands(tabType), chatItems: needWelcomeMessages ? [ - { + ...(this.regionProfileCard ? [this.regionProfileCard] : []), + { type: ChatItemType.ANSWER, body: this.tabWelcomeMessage.get(tabType), }, @@ -112,7 +129,7 @@ What would you like to work on?`, followUp: this.followUpsGenerator.generateWelcomeBlockForTab(tabType), }, ] - : [], + : [...(this.regionProfileCard ? [this.regionProfileCard] : [])], } } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/walkthrough/welcome.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/walkthrough/welcome.ts index e5fe67fdd7b..feb879d34f5 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/walkthrough/welcome.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/walkthrough/welcome.ts @@ -14,6 +14,14 @@ export const welcomeScreenTabData = (tabs: TabDataGenerator): MynahUITabStoreTab tabTitle: 'Welcome to Q', tabBackground: true, chatItems: [ + ...(tabs.profileName + ? [{ + type: ChatItemType.ANSWER, + icon: MynahIcons.INFO, + messageId: 'profile-info', + body: `You're using the ${tabs.profileName} profile for this chat period.`, + }] + : []), { type: ChatItemType.ANSWER, icon: MynahIcons.ASTERISK, diff --git a/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml b/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml index 6336e3526d6..95bb5c886e2 100644 --- a/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml +++ b/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml @@ -9,4 +9,5 @@ + diff --git a/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties b/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties index fab9733aa8d..ab7face28d9 100644 --- a/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties +++ b/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties @@ -1,4 +1,3 @@ -action.q.hello.description=Hello description amazonqInlineChat.hint.edit = Edit amazonqInlineChat.popup.accept=Accept \u23CE amazonqInlineChat.popup.cancel=Cancel \u238B @@ -10,4 +9,14 @@ amazonqInlineChat.popup.title=Enter Instructions for Q amazonq.refresh.panel=Refresh Chat Session amazonq.title=Amazon Q amazonq.workspace.settings.open.prompt=Workspace index is now enabled. You can disable it from Amazon Q settings. -q.hello=Hello +action.q.profile.usage.text=You changed your profile +action.q.profile.usage=You''re using the ''{0}'' profile for Amazon Q. +action.q.switchProfiles.text=Change Profile +action.q.switchProfiles.text.action_required=Change Profile Select a profile to proceed +action.q.switchProfiles.dialog.text=Amazon Q Developer Profile +action.q.switchProfiles.dialog.account.label=Account: {0} +action.q.switchProfiles.dialog.panel.text=Change your Q Developer profile +action.q.switchProfiles.dialog.panel.description=Choose the profile that meets your current working needs. +action.q.switchProfiles.dialog.panel.warning=When you change profiles, you will no longer have access to your current customizations, chats, code reviews, or any other code or content being generated by Amazon Q. +general.ok=OK +general.cancel=Cancel diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/CodeWhispererFeatureConfigService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/CodeWhispererFeatureConfigService.kt index 667e8e8f844..d77663430fb 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/CodeWhispererFeatureConfigService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/CodeWhispererFeatureConfigService.kt @@ -12,9 +12,9 @@ import software.amazon.awssdk.services.codewhispererruntime.model.FeatureValue import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.utils.isQExpired @Service @@ -32,8 +32,9 @@ class CodeWhispererFeatureConfigService { LOG.debug { "Fetching feature configs" } try { - val response = connection.getConnectionSettings().awsClient().listFeatureEvaluations { + val response = QRegionProfileManager.getInstance().getQClient(project).listFeatureEvaluations { it.userContext(codeWhispererUserContext()) + it.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) } ?: return // Simply force overwrite feature configs from server response, no needed to check existing values. @@ -113,7 +114,8 @@ class CodeWhispererFeatureConfigService { val availableCustomizations = calculateIfIamIdentityCenterConnection(project) { try { - connection.getConnectionSettings().awsClient().listAvailableCustomizationsPaginator {} + QRegionProfileManager.getInstance().getQClient(project) + .listAvailableCustomizationsPaginator {} .flatMap { resp -> resp.customizations().map { it.arn() diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/actions/QSwitchProfilesAction.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/actions/QSwitchProfilesAction.kt new file mode 100644 index 00000000000..9e6b40fd677 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/actions/QSwitchProfilesAction.kt @@ -0,0 +1,36 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import com.intellij.ui.JBColor +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileDialog +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager +import software.aws.toolkits.jetbrains.services.codewhisperer.util.getHexString +import software.aws.toolkits.resources.AmazonQBundle.message + +class QSwitchProfilesAction : AnAction(message("action.q.switchProfiles.text")), DumbAware { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.icon = AllIcons.Actions.SwapPanels + val project = e.project ?: return + if (QRegionProfileManager.getInstance().isPendingProfileSelection(project)) { + e.presentation.text = message("action.q.switchProfiles.text.action_required", JBColor.GRAY.getHexString()) + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + QRegionProfileDialog( + project, + selectedProfile = QRegionProfileManager.getInstance().activeProfile(project) + ).show() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClient.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClient.kt index 43c6e85c8b1..f4f31f715c6 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClient.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClient.kt @@ -16,20 +16,15 @@ import software.amazon.awssdk.services.codewhispererstreaming.model.ThrottlingEx import software.amazon.awssdk.services.codewhispererstreaming.model.ValidationException import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.services.amazonq.RetryableOperation +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import java.time.Instant import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicReference @Service(Service.Level.PROJECT) class AmazonQStreamingClient(private val project: Project) { - private fun connection() = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) - ?: error("Attempted to use connection while one does not exist") - - private fun streamingBearerClient() = connection().getConnectionSettings().awsClient() + private fun streamingBearerClient() = QRegionProfileManager.getInstance().getQClient(project) suspend fun exportResultArchive( exportId: String, @@ -66,6 +61,7 @@ class AmazonQStreamingClient(private val project: Project) { it.exportId(exportId) it.exportIntent(exportIntent) it.exportContext(exportContext) + it.profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn) }, ExportResultArchiveResponseHandler.builder().subscriber( ExportResultArchiveResponseHandler.Visitor.builder() diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt new file mode 100644 index 00000000000..8932881568f --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import org.eclipse.lsp4j.services.LanguageClient +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata +import java.util.concurrent.CompletableFuture + +/** + * Requests sent by server to client + */ +@Suppress("unused") +interface AmazonQLanguageClient : LanguageClient { + @JsonRequest("aws/credentials/getConnectionMetadata") + fun getConnectionMetadata(): CompletableFuture +} 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/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt new file mode 100644 index 00000000000..50b1be3626d --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt @@ -0,0 +1,96 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project +import org.eclipse.lsp4j.ConfigurationParams +import org.eclipse.lsp4j.MessageActionItem +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.MessageType +import org.eclipse.lsp4j.PublishDiagnosticsParams +import org.eclipse.lsp4j.ShowMessageRequestParams +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +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.model.aws.credentials.ConnectionMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import java.util.concurrent.CompletableFuture + +/** + * Concrete implementation of [AmazonQLanguageClient] to handle messages sent from server + */ +class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageClient { + override fun telemetryEvent(`object`: Any) { + println(`object`) + } + + override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) { + println(diagnostics) + } + + override fun showMessage(messageParams: MessageParams) { + val type = when (messageParams.type) { + MessageType.Error -> NotificationType.ERROR + MessageType.Warning -> NotificationType.WARNING + MessageType.Info, MessageType.Log -> NotificationType.INFORMATION + } + println("$type: ${messageParams.message}") + } + + override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture? { + println(requestParams) + + return CompletableFuture.completedFuture(null) + } + + override fun logMessage(message: MessageParams) { + showMessage(message) + } + + override fun getConnectionMetadata(): CompletableFuture = + CompletableFuture.supplyAsync { + val connection = ToolkitConnectionManager.getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) + + when (connection) { + is AwsBearerTokenConnection -> { + ConnectionMetadata( + SsoProfileData(connection.startUrl) + ) + } + else -> { + // If no connection or not a bearer token connection return default builderID start url + ConnectionMetadata( + SsoProfileData(AmazonQLspConstants.AWS_BUILDER_ID_URL) + ) + } + } + } + + override fun configuration(params: ConfigurationParams): CompletableFuture> { + if (params.items.isEmpty()) { + return CompletableFuture.completedFuture(null) + } + + return CompletableFuture.completedFuture( + buildList { + params.items.forEach { + when (it.section) { + AmazonQLspConstants.LSP_CW_CONFIGURATION_KEY -> { + add( + CodeWhispererLspConfiguration( + shouldShareData = CodeWhispererSettings.getInstance().isMetricOptIn(), + shouldShareCodeReferences = CodeWhispererSettings.getInstance().isIncludeCodeWithReference(), + shouldEnableWorkspaceContext = CodeWhispererSettings.getInstance().isWorkspaceContextEnabled() + ) + ) + } + } + } + } + ) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt new file mode 100644 index 00000000000..2396e273f18 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt @@ -0,0 +1,36 @@ +// 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 + +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.eclipse.lsp4j.jsonrpc.services.JsonNotification +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import org.eclipse.lsp4j.services.LanguageServer +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.UpdateConfigurationParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture + +/** + * Remote interface exposed by the Amazon Q language server + */ +@Suppress("unused") +interface AmazonQLanguageServer : LanguageServer { + @JsonNotification("aws/didChangeDependencyPaths") + fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams): CompletableFuture + + @JsonRequest("aws/credentials/token/update") + fun updateTokenCredentials(payload: UpdateCredentialsPayload): CompletableFuture + + @JsonNotification("aws/credentials/token/delete") + fun deleteTokenCredentials(): CompletableFuture + + @JsonRequest("aws/getConfigurationFromServer") + fun getConfigurationFromServer(params: GetConfigurationFromServerParams): CompletableFuture + + @JsonRequest("aws/updateConfiguration") + fun updateConfiguration(params: UpdateConfigurationParams): CompletableFuture +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt new file mode 100644 index 00000000000..ca8fffcbb51 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt @@ -0,0 +1,12 @@ +// 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 + +object AmazonQLspConstants { + const val AWS_BUILDER_ID_URL = "https://view.awsapps.com/start" + const val LSP_CW_CONFIGURATION_KEY = "aws.codeWhisperer" + const val LSP_CW_OPT_OUT_KEY = "shareCodeWhispererContentWithAWS" + const val LSP_CODE_REFERENCES_OPT_OUT_KEY = "includeSuggestionsWithCodeReferences" + const val LSP_WORKSPACE_CONTEXT_ENABLED_KEY = "workspaceContext" +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt new file mode 100644 index 00000000000..59658c3a878 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt @@ -0,0 +1,352 @@ +// 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 + +import com.google.gson.ToNumberPolicy +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.impl.ExecutionManagerImpl +import com.intellij.execution.process.KillableColoredProcessHandler +import com.intellij.execution.process.KillableProcessHandler +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.process.ProcessOutputType +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.io.await +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.eclipse.lsp4j.ClientCapabilities +import org.eclipse.lsp4j.ClientInfo +import org.eclipse.lsp4j.DidChangeConfigurationParams +import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities +import org.eclipse.lsp4j.InitializeParams +import org.eclipse.lsp4j.InitializeResult +import org.eclipse.lsp4j.InitializedParams +import org.eclipse.lsp4j.SynchronizationCapabilities +import org.eclipse.lsp4j.TextDocumentClientCapabilities +import org.eclipse.lsp4j.WorkspaceClientCapabilities +import org.eclipse.lsp4j.jsonrpc.Launcher +import org.eclipse.lsp4j.launch.LSPLauncher +import org.slf4j.event.Level +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.isDeveloperMode +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.DefaultModuleDependenciesService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDocumentServiceHandler +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders +import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata +import software.aws.toolkits.jetbrains.settings.LspSettings +import java.io.IOException +import java.io.OutputStreamWriter +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.io.PrintWriter +import java.io.StringWriter +import java.nio.charset.StandardCharsets +import java.util.concurrent.Future +import kotlin.time.Duration.Companion.seconds + +// https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java +// JB impl and redhat both use a wrapper to handle input buffering issue +internal class LSPProcessListener : ProcessListener { + private val outputStream = PipedOutputStream() + private val outputStreamWriter = OutputStreamWriter(outputStream, StandardCharsets.UTF_8) + val inputStream = PipedInputStream(outputStream) + + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (ProcessOutputType.isStdout(outputType)) { + try { + this.outputStreamWriter.write(event.text) + this.outputStreamWriter.flush() + } catch (_: IOException) { + ExecutionManagerImpl.stopProcess(event.processHandler) + } + } else if (ProcessOutputType.isStderr(outputType)) { + LOG.warn { "LSP process stderr: ${event.text}" } + } + } + + override fun processTerminated(event: ProcessEvent) { + try { + this.outputStreamWriter.close() + this.outputStream.close() + } catch (_: IOException) { + } + } + + companion object { + private val LOG = getLogger() + } +} + +@Service(Service.Level.PROJECT) +class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable { + private var instance: Deferred + val capabilities + get() = instance.getCompleted().initializeResult.getCompleted().capabilities + + // dont allow lsp commands if server is restarting + private val mutex = Mutex(false) + + private fun start() = cs.async { + // manage lifecycle RAII-like so we can restart at arbitrary time + // and suppress IDE error if server fails to start + var attempts = 0 + while (attempts < 3) { + try { + return@async withTimeout(30.seconds) { + val instance = AmazonQServerInstance(project, cs).also { + Disposer.register(this@AmazonQLspService, it) + } + // wait for handshake to complete + instance.initializeResult.join() + + instance + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to start LSP server" } + } + attempts++ + } + + error("Failed to start LSP server in 3 attempts") + } + + init { + instance = start() + } + + override fun dispose() { + } + + suspend fun restart() = mutex.withLock { + // stop if running + instance.let { + if (it.isActive) { + // not even running yet + return + } + + try { + val i = it.await() + if (i.initializeResult.isActive) { + // not initialized + return + } + + Disposer.dispose(i) + } catch (e: Exception) { + LOG.info(e) { "Exception while disposing LSP server" } + } + } + + instance = start() + } + + suspend fun execute(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T { + val lsp = withTimeout(10.seconds) { + val holder = mutex.withLock { instance }.await() + holder.initializeResult.join() + + holder.languageServer + } + return runnable(lsp) + } + + fun executeSync(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T = + runBlocking(cs.coroutineContext) { + execute(runnable) + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project) = project.service() + + fun executeIfRunning(project: Project, runnable: AmazonQLspService.(AmazonQLanguageServer) -> T): T? = + project.serviceIfCreated()?.executeSync(runnable) + + fun didChangeConfiguration(project: Project) { + executeIfRunning(project) { + it.workspaceService.didChangeConfiguration(DidChangeConfigurationParams()) + } + } + } +} + +private class AmazonQServerInstance(private val project: Project, private val cs: CoroutineScope) : Disposable { + private val encryptionManager = JwtEncryptionManager() + + private val launcher: Launcher + + val languageServer: AmazonQLanguageServer + get() = launcher.remoteProxy + + @Suppress("ForbiddenVoid") + private val launcherFuture: Future + private val launcherHandler: KillableProcessHandler + val initializeResult: Deferred + + private fun createClientCapabilities(): ClientCapabilities = + ClientCapabilities().apply { + textDocument = TextDocumentClientCapabilities().apply { + // For didSaveTextDocument, other textDocument/ messages always mandatory + synchronization = SynchronizationCapabilities().apply { + didSave = true + } + } + + workspace = WorkspaceClientCapabilities().apply { + applyEdit = false + + // For workspace folder changes + workspaceFolders = true + + // For file operations (create, delete) + fileOperations = FileOperationsWorkspaceCapabilities().apply { + didCreate = true + didDelete = true + didRename = true + } + } + } + + private fun createClientInfo(): ClientInfo { + val metadata = ClientMetadata.getDefault() + return ClientInfo().apply { + name = metadata.awsProduct.toString() + version = metadata.awsVersion + } + } + + private fun createInitializeParams(): InitializeParams = + InitializeParams().apply { + processId = ProcessHandle.current().pid().toInt() + capabilities = createClientCapabilities() + clientInfo = createClientInfo() + workspaceFolders = createWorkspaceFolders(project) + initializationOptions = createExtendedClientMetadata() + } + + init { + // will cause slow service init, but maybe fine for now. will not block UI since fetch/extract will be under background progress + val artifact = runBlocking { ArtifactManager(project, manifestRange = null).fetchArtifact() }.toAbsolutePath() + val node = if (SystemInfo.isWindows) "node.exe" else "node" + val cmd = GeneralCommandLine( + artifact.resolve(node).toString(), + LspSettings.getInstance().getArtifactPath() ?: artifact.resolve("aws-lsp-codewhisperer.js").toString(), + "--stdio", + "--set-credentials-encryption-key", + ) + + launcherHandler = KillableColoredProcessHandler.Silent(cmd) + val inputWrapper = LSPProcessListener() + launcherHandler.addProcessListener(inputWrapper) + launcherHandler.startNotify() + + launcher = LSPLauncher.Builder() + .setLocalService(AmazonQLanguageClientImpl(project)) + .setRemoteInterface(AmazonQLanguageServer::class.java) + .configureGson { + // TODO: maybe need adapter for initialize: + // https://github.com/aws/amazon-q-eclipse/blob/b9d5bdcd5c38e1dd8ad371d37ab93a16113d7d4b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QLspTypeAdapterFactory.java + + // otherwise Gson treats all numbers as double which causes deser issues + it.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + }.traceMessages( + PrintWriter( + object : StringWriter() { + private val traceLogger = LOG.atLevel(if (isDeveloperMode()) Level.INFO else Level.DEBUG) + + override fun flush() { + traceLogger.log { buffer.toString() } + buffer.setLength(0) + } + } + ) + ) + .setInput(inputWrapper.inputStream) + .setOutput(launcherHandler.process.outputStream) + .create() + + launcherFuture = launcher.startListening() + + initializeResult = cs.async { + // encryption info must be sent within 5s or Flare process will exit + encryptionManager.writeInitializationPayload(launcherHandler.process.outputStream) + + val initializeResult = try { + withTimeout(5.seconds) { + languageServer.initialize(createInitializeParams()).await() + } + } catch (_: TimeoutCancellationException) { + LOG.warn { "LSP initialization timed out" } + null + } catch (e: Exception) { + LOG.warn(e) { "LSP initialization failed" } + null + } + + // then if this succeeds then we can allow the client to send requests + if (initializeResult == null) { + launcherHandler.destroyProcess() + error("LSP initialization failed") + } + languageServer.initialized(InitializedParams()) + + initializeResult + } + + // invokeOnCompletion results in weird lock/timeout error + initializeResult.asCompletableFuture().handleAsync { r, ex -> + if (ex != null) { + return@handleAsync + } + + this@AmazonQServerInstance.apply { + DefaultAuthCredentialsService(project, encryptionManager, this) + TextDocumentServiceHandler(project, this) + WorkspaceServiceHandler(project, this) + DefaultModuleDependenciesService(project, this) + } + } + } + + override fun dispose() { + if (!launcherFuture.isDone) { + try { + languageServer.apply { + shutdown().thenRun { exit() } + } + } catch (e: Exception) { + LOG.warn(e) { "LSP shutdown failed" } + launcherHandler.destroyProcess() + } + } else if (!launcherHandler.isProcessTerminated) { + launcherHandler.destroyProcess() + } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt new file mode 100644 index 00000000000..d54acf55fbe --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt @@ -0,0 +1,17 @@ +// 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 + +import com.google.gson.annotations.SerializedName + +data class CodeWhispererLspConfiguration( + @SerializedName(AmazonQLspConstants.LSP_CW_OPT_OUT_KEY) + val shouldShareData: Boolean? = null, + + @SerializedName(AmazonQLspConstants.LSP_WORKSPACE_CONTEXT_ENABLED_KEY) + val shouldEnableWorkspaceContext: Boolean? = null, + + @SerializedName(AmazonQLspConstants.LSP_CODE_REFERENCES_OPT_OUT_KEY) + val shouldShareCodeReferences: Boolean? = null, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt new file mode 100644 index 00000000000..8787259bf08 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt @@ -0,0 +1,215 @@ +// 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.openapi.project.Project +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.util.io.createDirectories +import com.intellij.util.text.SemVer +import kotlinx.coroutines.CancellationException +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +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.saveFileFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import software.aws.toolkits.resources.AwsCoreBundle +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger + +class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH, private val maxDownloadAttempts: Int = MAX_DOWNLOAD_ATTEMPTS) { + + companion object { + private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers") + private val logger = getLogger() + private const val MAX_DOWNLOAD_ATTEMPTS = 3 + } + private val currentAttempt = AtomicInteger(0) + + fun removeDelistedVersions(delistedVersions: List) { + val localFolders = getSubFolders(lspArtifactsPath) + + delistedVersions.forEach { delistedVersion -> + val versionToDelete = delistedVersion.serverVersion ?: return@forEach + + localFolders + .filter { folder -> folder.fileName.toString() == versionToDelete } + .forEach { folder -> + try { + folder.toFile().deleteRecursively() + logger.info { "Successfully deleted deListed version: ${folder.fileName}" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" } + } + } + } + } + + fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) { + val validVersions = getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges) + + // Keep the latest 2 versions, delete others + validVersions.drop(2).forEach { (folder, _) -> + try { + folder.toFile().deleteRecursively() + logger.info { "Deleted older LSP artifact: ${folder.fileName}" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" } + } + } + } + + fun getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange): List> { + val localFolders = getSubFolders(lspArtifactsPath) + + return localFolders + .mapNotNull { localFolder -> + SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer -> + if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) { + localFolder to semVer + } else { + null + } + } + } + .sortedByDescending { (_, semVer) -> semVer } + } + + fun getExistingLspArtifacts(versions: List, target: ManifestManager.VersionTarget?): Boolean { + if (versions.isEmpty() || target?.contents == null) return false + + val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) + if (!localLSPPath.exists()) return false + + val hasInvalidFiles = target.contents.any { content -> + content.filename?.let { filename -> + val filePath = localLSPPath.resolve(filename) + !filePath.exists() || !validateFileHash(filePath, content.hashes?.firstOrNull()) + } ?: false + } + + if (hasInvalidFiles) { + try { + localLSPPath.toFile().deleteRecursively() + logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" } + } + } + return !hasInvalidFiles + } + + suspend fun tryDownloadLspArtifacts(project: Project, versions: List, target: ManifestManager.VersionTarget?): Path? { + val temporaryDownloadPath = lspArtifactsPath.resolve("temp") + val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) + + while (currentAttempt.get() < maxDownloadAttempts) { + currentAttempt.incrementAndGet() + logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" } + + try { + return withBackgroundProgress( + project, + AwsCoreBundle.message("amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts"), + cancellable = true + ) { + if (downloadLspArtifacts(temporaryDownloadPath, target) && target != null && !target.contents.isNullOrEmpty()) { + moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath) + target.contents + .mapNotNull { it.filename } + .forEach { filename -> extractZipFile(downloadPath.resolve(filename), downloadPath) } + logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" } + + return@withBackgroundProgress downloadPath + } + + return@withBackgroundProgress null + } + } catch (e: Exception) { + when (e) { + is CancellationException -> { + logger.error(e) { "User cancelled download and extracting of LSP artifacts.." } + currentAttempt.set(maxDownloadAttempts) // To exit the while loop. + } + else -> { logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" } } + } + temporaryDownloadPath.toFile().deleteRecursively() + downloadPath.toFile().deleteRecursively() + } + } + logger.error { "Failed to download LSP artifacts after $maxDownloadAttempts attempts" } + return null + } + + @VisibleForTesting + internal fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean { + if (target == null || target.contents.isNullOrEmpty()) { + logger.warn { "No target contents available for download" } + return false + } + try { + downloadPath.createDirectories() + target.contents.forEach { content -> + if (content.url == null || content.filename == null) { + logger.warn { "Missing URL or filename in content" } + return@forEach + } + val filePath = downloadPath.resolve(content.filename) + val contentHash = content.hashes?.firstOrNull() ?: run { + logger.warn { "No hash available for ${content.filename}" } + return@forEach + } + downloadAndValidateFile(content.url, filePath, contentHash) + } + validateDownloadedFiles(downloadPath, target.contents) + } catch (e: Exception) { + logger.error(e) { "Failed to download LSP artifacts: ${e.message}" } + downloadPath.toFile().deleteRecursively() + return false + } + return true + } + + private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) { + try { + if (!filePath.exists()) { + logger.info { "Downloading file: ${filePath.fileName}" } + saveFileFromUrl(url, filePath) + } + if (!validateFileHash(filePath, expectedHash)) { + logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" } + filePath.deleteIfExists() + saveFileFromUrl(url, filePath) + if (!validateFileHash(filePath, expectedHash)) { + throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH) + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e) + } + } + + @VisibleForTesting + internal fun validateFileHash(filePath: Path, expectedHash: String?): Boolean { + if (expectedHash == null) return false + val contentHash = generateSHA384Hash(filePath) + return "sha384:$contentHash" == expectedHash + } + + private fun validateDownloadedFiles(downloadPath: Path, contents: List) { + val missingFiles = contents + .mapNotNull { it.filename } + .filter { filename -> + !downloadPath.resolve(filename).exists() + } + if (missingFiles.isNotEmpty()) { + val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}" + logger.error { errorMessage } + throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED) + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt new file mode 100644 index 00000000000..b74bbde2886 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt @@ -0,0 +1,108 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.project.Project +import com.intellij.util.text.SemVer +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +class ArtifactManager( + private val project: Project, + private val manifestFetcher: ManifestFetcher = ManifestFetcher(), + private val artifactHelper: ArtifactHelper = ArtifactHelper(), + manifestRange: SupportedManifestVersionRange?, +) { + + data class SupportedManifestVersionRange( + val startVersion: SemVer, + val endVersion: SemVer, + ) + data class LSPVersions( + val deListedVersions: List, + val inRangeVersions: List, + ) + + private val manifestVersionRanges: SupportedManifestVersionRange = manifestRange ?: DEFAULT_VERSION_RANGE + + companion object { + private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + private val logger = getLogger() + } + + suspend fun fetchArtifact(): Path { + val manifest = manifestFetcher.fetch() ?: throw LspException( + "Language Support is not available, as manifest is missing.", + LspException.ErrorCode.MANIFEST_FETCH_FAILED + ) + val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest) + + this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions) + + if (lspVersions.inRangeVersions.isEmpty()) { + // No versions are found which are in the given range. Fallback to local lsp artifacts. + val localLspArtifacts = this.artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges) + if (localLspArtifacts.isNotEmpty()) { + return localLspArtifacts.first().first + } + throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION) + } + + // If there is an LSP Manifest with the same version + val target = getTargetFromLspManifest(lspVersions.inRangeVersions) + // Get Local LSP files and check if we can re-use existing LSP Artifacts + val artifactPath: Path = if (this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) { + this.artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges).first().first + } else { + this.artifactHelper.tryDownloadLspArtifacts(project, lspVersions.inRangeVersions, target) + ?: throw LspException("Failed to download LSP artifacts", LspException.ErrorCode.DOWNLOAD_FAILED) + } + this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) + return artifactPath + } + + @VisibleForTesting + internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions { + if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList()) + + val (deListed, inRange) = manifest.versions.mapNotNull { version -> + version.serverVersion?.let { serverVersion -> + SemVer.parseFromText(serverVersion)?.let { semVer -> + when { + version.isDelisted != false -> Pair(version, true) // Is deListed + semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range + else -> null + } + } + } + }.partition { it.second } + + return LSPVersions( + deListedVersions = deListed.map { it.first }, + inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer } + ) + } + + private fun getTargetFromLspManifest(versions: List): ManifestManager.VersionTarget { + val currentOS = getCurrentOS() + val currentArchitecture = getCurrentArchitecture() + + val currentTarget = versions.first().targets?.find { target -> + target.platform == currentOS && target.arch == currentArchitecture + } + if (currentTarget == null) { + logger.error { "Failed to obtain target for $currentOS and $currentArchitecture" } + throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND) + } + logger.info { "Target found in the current Version: ${versions.first().serverVersion}" } + return currentTarget + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt new file mode 100644 index 00000000000..110acd14b5d --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt @@ -0,0 +1,21 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) { + + enum class ErrorCode { + MANIFEST_FETCH_FAILED, + DOWNLOAD_FAILED, + HASH_MISMATCH, + TARGET_NOT_FOUND, + NO_COMPATIBLE_LSP_VERSION, + UNZIP_FAILED, + } + + override fun toString(): String = buildString { + append("LSP Error [$errorCode]: $message") + cause?.let { append(", Cause: ${it.message}") } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt new file mode 100644 index 00000000000..7724a8c2255 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt @@ -0,0 +1,101 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.io.DigestUtil +import com.intellij.util.system.CpuArch +import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX +import software.aws.toolkits.core.utils.createParentDirectories +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.hasPosixFilePermissions +import java.io.FileNotFoundException +import java.net.URI +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries + +fun getToolkitsCommonCacheRoot(): Path = when { + SystemInfo.isWindows -> { + Paths.get(System.getenv("LOCALAPPDATA")) + } + SystemInfo.isMac -> { + Paths.get(System.getProperty("user.home"), "Library", "Caches") + } + else -> { + Paths.get(System.getProperty("user.home"), ".cache") + } +} + +fun getCurrentOS(): String = when { + SystemInfo.isWindows -> "windows" + SystemInfo.isMac -> "darwin" + else -> "linux" +} + +fun getCurrentArchitecture() = when (CpuArch.CURRENT) { + CpuArch.X86_64 -> "x64" + CpuArch.ARM64 -> "arm64" + else -> "unknown" +} + +fun generateMD5Hash(filePath: Path): String { + val messageDigest = DigestUtil.md5() + DigestUtil.updateContentHash(messageDigest, filePath) + return StringUtil.toHexString(messageDigest.digest()) +} + +fun generateSHA384Hash(filePath: Path): String { + val messageDigest = MessageDigest.getInstance("SHA-384") + DigestUtil.updateContentHash(messageDigest, filePath) + return StringUtil.toHexString(messageDigest.digest()) +} + +fun getSubFolders(basePath: Path): List = try { + basePath.listDirectoryEntries() + .filter { it.isDirectory() } +} catch (e: Exception) { + emptyList() +} + +fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) { + try { + Files.createDirectories(targetDir.parent) + Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING) + } catch (e: Exception) { + throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e) + } +} + +fun extractZipFile(zipFilePath: Path, destDir: Path) { + if (!zipFilePath.exists()) { + throw FileNotFoundException("Zip file not found: $zipFilePath") + } + + try { + FileSystems.newFileSystem( + // jar prefix due to potentially ambiguous resolution to wrong fs impl for zipfs on windows + URI("jar:${zipFilePath.toUri()}"), + mapOf(ZIP_PROPERTY_POSIX to destDir.hasPosixFilePermissions()) + ).use { zipfs -> + Files.walk(zipfs.getPath("/")).use { paths -> + paths + .filter { !it.isDirectory() } + .forEach { zipEntry -> + val destPath = Paths.get(destDir.toString(), zipEntry.toString()) + destPath.createParentDirectories() + Files.copy(zipEntry, destPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES) + } + } + } + } catch (e: Exception) { + throw LspException("Failed to extract zip file: ${e.message}", LspException.ErrorCode.UNZIP_FAILED, cause = e) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt new file mode 100644 index 00000000000..74656d5665b --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt @@ -0,0 +1,115 @@ +// 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 org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.readText +import software.aws.toolkits.jetbrains.core.getETagFromUrl +import software.aws.toolkits.jetbrains.core.getTextFromUrl +import software.aws.toolkits.jetbrains.core.saveFileFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +class ManifestFetcher( + private val lspManifestUrl: String = DEFAULT_MANIFEST_URL, + private val manifestManager: ManifestManager = ManifestManager(), + private val manifestPath: Path = DEFAULT_MANIFEST_PATH, +) { + companion object { + private val logger = getLogger() + + private const val DEFAULT_MANIFEST_URL = + "https://aws-toolkit-language-servers.amazonaws.com/remoteWorkspaceContext/0/manifest.json" + + private val DEFAULT_MANIFEST_PATH: Path = getToolkitsCommonCacheRoot() + .resolve("aws") + .resolve("toolkits") + .resolve("language-servers") + .resolve("jetbrains-lsp-manifest.json") + } + + @get:VisibleForTesting + internal val lspManifestFilePath: Path + get() = manifestPath + + /** + * Method which will be used to fetch latest manifest. + * */ + fun fetch(): ManifestManager.Manifest? { + val localManifest = fetchManifestFromLocal() + if (localManifest != null) { + return localManifest + } + return fetchManifestFromRemote() + } + + @VisibleForTesting + internal fun fetchManifestFromRemote(): ManifestManager.Manifest? { + val manifest: ManifestManager.Manifest? + try { + val manifestString = getTextFromUrl(lspManifestUrl) + manifest = manifestManager.readManifestFile(manifestString) ?: return null + } catch (e: Exception) { + logger.error(e) { "error fetching lsp manifest from remote URL ${e.message}" } + return null + } + if (manifest.isManifestDeprecated == true) { + logger.info { "Manifest is deprecated" } + return null + } + updateManifestCache() + logger.info { "Using manifest found from remote URL" } + return manifest + } + + private fun updateManifestCache() { + try { + saveFileFromUrl(lspManifestUrl, lspManifestFilePath) + } catch (e: Exception) { + logger.error(e) { "error occurred while saving lsp manifest to local cache ${e.message}" } + } + } + + @VisibleForTesting + internal fun fetchManifestFromLocal(): ManifestManager.Manifest? { + val localETag = getManifestETagFromLocal() + val remoteETag = getManifestETagFromUrl() + // If local and remote have same ETag, we can re-use the manifest file from local to fetch artifacts. + // If remote manifest is null or system is offline, re-use localManifest + if ((localETag != null && remoteETag != null && localETag == remoteETag) or (localETag != null && remoteETag == null)) { + try { + val manifestContent = lspManifestFilePath.readText() + val manifest = manifestManager.readManifestFile(manifestContent) + if (manifest != null) return manifest + lspManifestFilePath.deleteIfExists() // delete manifest if it fails to de-serialize + } catch (e: Exception) { + logger.error(e) { "error reading lsp manifest file from local ${e.message}" } + return null + } + } + return null + } + + private fun getManifestETagFromLocal(): String? { + if (lspManifestFilePath.exists()) { + return generateMD5Hash(lspManifestFilePath) + } + return null + } + + private fun getManifestETagFromUrl(): String? { + try { + val actualETag = getETagFromUrl(lspManifestUrl) + return actualETag.trim('"') + } catch (e: Exception) { + logger.error(e) { "error fetching ETag of lsp manifest from url." } + } + return null + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt new file mode 100644 index 00000000000..a38c8da4bbc --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt @@ -0,0 +1,12 @@ +// 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 org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import java.util.concurrent.CompletableFuture + +interface AuthCredentialsService { + fun updateTokenCredentials(accessToken: String, encrypted: Boolean): CompletableFuture + fun deleteTokenCredentials(): CompletableFuture +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt new file mode 100644 index 00000000000..d3a99a1f4fe --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt @@ -0,0 +1,137 @@ +// 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.project.Project +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +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.UpdateConfigurationParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.BearerCredentials +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayloadData +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener +import software.aws.toolkits.jetbrains.utils.isQConnected +import software.aws.toolkits.jetbrains.utils.isQExpired +import java.util.concurrent.CompletableFuture + +class DefaultAuthCredentialsService( + private val project: Project, + private val encryptionManager: JwtEncryptionManager, + serverInstance: Disposable, +) : AuthCredentialsService, + BearerTokenProviderListener, + ToolkitConnectionManagerListener, + QRegionProfileSelectedListener { + + init { + project.messageBus.connect(serverInstance).apply { + subscribe(BearerTokenProviderListener.TOPIC, this@DefaultAuthCredentialsService) + subscribe(ToolkitConnectionManagerListener.TOPIC, this@DefaultAuthCredentialsService) + subscribe(QRegionProfileSelectedListener.TOPIC, this@DefaultAuthCredentialsService) + } + + if (isQConnected(project) && !isQExpired(project)) { + updateTokenFromActiveConnection() + .thenRun { + updateConfiguration() + } + } + } + + override fun updateTokenCredentials(accessToken: String, encrypted: Boolean): CompletableFuture { + val payload = createUpdateCredentialsPayload(accessToken, encrypted) + + return AmazonQLspService.executeIfRunning(project) { server -> + server.updateTokenCredentials(payload) + } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) + } + + override fun deleteTokenCredentials(): CompletableFuture = + CompletableFuture().also { completableFuture -> + AmazonQLspService.executeIfRunning(project) { server -> + server.deleteTokenCredentials() + completableFuture.complete(null) + } ?: completableFuture.completeExceptionally(IllegalStateException("LSP Server not running")) + } + + override fun onChange(providerId: String, newScopes: List?) { + updateTokenFromActiveConnection() + } + + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + val qConnection = ToolkitConnectionManager.getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) + ?: return + if (newConnection?.id != qConnection.id) return + + updateTokenFromConnection(newConnection) + } + + private fun updateTokenFromActiveConnection(): CompletableFuture { + val connection = ToolkitConnectionManager.getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) + ?: return CompletableFuture.failedFuture(IllegalStateException("No active Q connection")) + + return updateTokenFromConnection(connection) + } + + private fun updateTokenFromConnection(connection: ToolkitConnection): CompletableFuture = + (connection.getConnectionSettings() as? TokenConnectionSettings) + ?.tokenProvider + ?.delegate + ?.let { it as? BearerTokenProvider } + ?.currentToken() + ?.accessToken + ?.let { token -> updateTokenCredentials(token, true) } + ?: CompletableFuture.failedFuture(IllegalStateException("Unable to get token from connection")) + + override fun invalidate(providerId: String) { + deleteTokenCredentials() + } + + private fun createUpdateCredentialsPayload(token: String, encrypted: Boolean): UpdateCredentialsPayload = + if (encrypted) { + UpdateCredentialsPayload( + data = encryptionManager.encrypt( + UpdateCredentialsPayloadData( + BearerCredentials(token) + ) + ), + encrypted = true + ) + } else { + UpdateCredentialsPayload( + data = token, + encrypted = false + ) + } + + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + updateConfiguration() + } + + private fun updateConfiguration(): CompletableFuture { + val payload = UpdateConfigurationParams( + section = "aws.q", + settings = mapOf( + "profileArn" to QRegionProfileManager.getInstance().activeProfile(project)?.arn + ) + ) + return AmazonQLspService.executeIfRunning(project) { server -> + server.updateConfiguration(payload) + } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt new file mode 100644 index 00000000000..80239696d14 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt @@ -0,0 +1,52 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies + +import com.intellij.openapi.Disposable +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootEvent +import com.intellij.openapi.roots.ModuleRootListener +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider.Companion.EP_NAME +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture + +class DefaultModuleDependenciesService( + private val project: Project, + serverInstance: Disposable, +) : ModuleDependenciesService, + ModuleRootListener { + + init { + project.messageBus.connect(serverInstance).subscribe( + ModuleRootListener.TOPIC, + this + ) + // project initiation with initial list of dependencies + syncAllModules() + } + + override fun rootsChanged(event: ModuleRootEvent) { + if (event.isCausedByFileTypesChange) return + // call on change with updated dependencies + syncAllModules() + } + + override fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams): CompletableFuture = + AmazonQLspService.executeIfRunning(project) { languageServer -> + languageServer.didChangeDependencyPaths(params) + }?.toCompletableFuture() ?: CompletableFuture.failedFuture(IllegalStateException("LSP Server not running")) + + private fun syncAllModules() { + ModuleManager.getInstance(project).modules.forEach { module -> + EP_NAME.forEachExtensionSafe { + if (it.isApplicable(module)) { + didChangeDependencyPaths(it.createParams(module)) + return@forEachExtensionSafe + } + } + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt new file mode 100644 index 00000000000..82370dad895 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt @@ -0,0 +1,11 @@ +// 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.dependencies + +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture + +interface ModuleDependenciesService { + fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams): CompletableFuture +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt new file mode 100644 index 00000000000..e8d0087e7d2 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt @@ -0,0 +1,25 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.module.Module +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString + +interface ModuleDependencyProvider { + companion object { + val EP_NAME = ExtensionPointName("software.aws.toolkits.jetbrains.moduleDependencyProvider") + } + + fun isApplicable(module: Module): Boolean + fun createParams(module: Module): DidChangeDependencyPathsParams + + fun getWorkspaceFolderPath(module: Module): String { + val contentRoots: Array = ModuleRootManager.getInstance(module).contentRoots + return contentRoots.firstOrNull()?.let { toUriString(it) }.orEmpty() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt new file mode 100644 index 00000000000..25d1d36c00f --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt @@ -0,0 +1,35 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.providers + +import com.intellij.openapi.module.Module +import com.intellij.openapi.projectRoots.JavaSdkType +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.roots.OrderRootType +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams + +internal class JavaModuleDependencyProvider : ModuleDependencyProvider { + override fun isApplicable(module: Module): Boolean = + ModuleRootManager.getInstance(module).sdk?.sdkType is JavaSdkType + + override fun createParams(module: Module): DidChangeDependencyPathsParams { + val dependencies = mutableListOf() + + ModuleRootManager.getInstance(module).orderEntries().forEachLibrary { library -> + library.getFiles(OrderRootType.CLASSES).forEach { file -> + dependencies.add(file.path.removeSuffix("!/")) + } + true + } + + return DidChangeDependencyPathsParams( + moduleName = getWorkspaceFolderPath(module), + runtimeLanguage = "java", + paths = dependencies, + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt new file mode 100644 index 00000000000..9a7961d4fbe --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt @@ -0,0 +1,39 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.providers + +import com.intellij.openapi.module.Module +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.sdk.PythonSdkUtil +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams + +internal class PythonModuleDependencyProvider : ModuleDependencyProvider { + override fun isApplicable(module: Module): Boolean = + PythonSdkUtil.findPythonSdk(module) != null + + override fun createParams(module: Module): DidChangeDependencyPathsParams { + val dependencies = mutableListOf() + + PythonSdkUtil.findPythonSdk(module)?.let { sdk -> + PythonSdkUtil.getSitePackagesDirectory(sdk)?.let { sitePackagesDir -> + val packageManager = PythonPackageManager.forSdk(module.project, sdk) + packageManager.installedPackages.forEach { pkg -> + val packageDir = sitePackagesDir.findChild(pkg.name) + if (packageDir != null) { + dependencies.add(packageDir.path) + } + } + } + } + + return DidChangeDependencyPathsParams( + moduleName = getWorkspaceFolderPath(module), + runtimeLanguage = "python", + paths = dependencies, + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt new file mode 100644 index 00000000000..bca385682ea --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt @@ -0,0 +1,65 @@ +// 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.encryption + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.DirectDecrypter +import com.nimbusds.jose.crypto.DirectEncrypter +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.EncryptionInitializationRequest +import java.io.OutputStream +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +class JwtEncryptionManager(private val key: SecretKey) { + constructor() : this(generateHmacKey()) + + private val mapper = jacksonObjectMapper() + + fun writeInitializationPayload(os: OutputStream) { + val payload = EncryptionInitializationRequest( + EncryptionInitializationRequest.Version.V1_0, + EncryptionInitializationRequest.Mode.JWT, + Base64.getUrlEncoder().withoutPadding().encodeToString(key.encoded) + ) + + // write directly to stream because utils are closing the underlying stream + os.write("${mapper.writeValueAsString(payload)}\n".toByteArray()) + } + + fun encrypt(data: Any): String { + val header = JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM) + val payload = if (data is String) { + Payload(data) + } else { + Payload(mapper.writeValueAsBytes(data)) + } + + val jweObject = JWEObject(header, payload) + jweObject.encrypt(DirectEncrypter(key)) + + return jweObject.serialize() + } + + fun decrypt(jwt: String): String { + val jweObject = JWEObject.parse(jwt) + jweObject.decrypt(DirectDecrypter(key)) + + return jweObject.payload.toString() + } + + private companion object { + private fun generateHmacKey(): SecretKey { + val keyBytes = ByteArray(32) + SecureRandom().nextBytes(keyBytes) + return SecretKeySpec(keyBytes, "HmacSHA256") + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt new file mode 100644 index 00000000000..53748475195 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt @@ -0,0 +1,20 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model + +import com.fasterxml.jackson.annotation.JsonValue + +data class EncryptionInitializationRequest( + val version: Version, + val mode: Mode, + val key: String, +) { + enum class Version(@JsonValue val value: String) { + V1_0("1.0"), + } + + enum class Mode(@JsonValue val value: String) { + JWT("JWT"), + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt new file mode 100644 index 00000000000..b25150c36bb --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt @@ -0,0 +1,57 @@ +// 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.model + +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata + +data class ExtendedClientMetadata( + val aws: AwsMetadata, +) + +data class AwsMetadata( + val clientInfo: ClientInfoMetadata, + val awsClientCapabilities: AwsClientCapabilities, +) + +data class AwsClientCapabilities( + val q: DeveloperProfiles, +) + +data class DeveloperProfiles( + val developerProfiles: Boolean, +) + +data class ClientInfoMetadata( + val extension: ExtensionMetadata, + val clientId: String, + val version: String, + val name: String, +) + +data class ExtensionMetadata( + val name: String, + val version: String, +) + +fun createExtendedClientMetadata(): ExtendedClientMetadata { + val metadata = ClientMetadata.getDefault() + return ExtendedClientMetadata( + aws = AwsMetadata( + clientInfo = ClientInfoMetadata( + extension = ExtensionMetadata( + name = metadata.awsProduct.toString(), + version = metadata.awsVersion + ), + clientId = metadata.clientId, + version = metadata.parentProductVersion, + name = metadata.parentProduct + ), + awsClientCapabilities = AwsClientCapabilities( + q = DeveloperProfiles( + developerProfiles = true + ) + ) + ) + ) +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/GetConfigurationFromServerParams.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/GetConfigurationFromServerParams.kt new file mode 100644 index 00000000000..551ddfa97b0 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/GetConfigurationFromServerParams.kt @@ -0,0 +1,8 @@ +// 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.model.aws + +data class GetConfigurationFromServerParams( + val section: String, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/LspServerConfigurations.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/LspServerConfigurations.kt new file mode 100644 index 00000000000..a0f23875b62 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/LspServerConfigurations.kt @@ -0,0 +1,17 @@ +// 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.model.aws + +// This represents each item in the array +data class WorkspaceInfo(val workspaceRoot: String, val workspaceId: String) + +// This represents the entire array +data class LspServerConfigurations(val workspaces: List) + +data class UpdateConfigurationParams( + val section: String, + val settings: LSPAny, +) + +typealias LSPAny = Any? diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt new file mode 100644 index 00000000000..c6216b97cff --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt @@ -0,0 +1,12 @@ +// 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.model.aws.credentials + +data class ConnectionMetadata( + val sso: SsoProfileData, +) + +data class SsoProfileData( + val startUrl: String, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt new file mode 100644 index 00000000000..a427330c055 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt @@ -0,0 +1,17 @@ +// 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.model.aws.credentials + +data class UpdateCredentialsPayload( + val data: String, + val encrypted: Boolean, +) + +data class UpdateCredentialsPayloadData( + val data: BearerCredentials, +) + +data class BearerCredentials( + val token: String, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/DidChangeDependencyPathsParams.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/DidChangeDependencyPathsParams.kt new file mode 100644 index 00000000000..8436b985825 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/DidChangeDependencyPathsParams.kt @@ -0,0 +1,12 @@ +// 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.model.aws.dependencies + +class DidChangeDependencyPathsParams( + val moduleName: String, + val runtimeLanguage: String, + val paths: List, + val includePatterns: List, + val excludePatterns: List, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt new file mode 100644 index 00000000000..bbf60200810 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -0,0 +1,144 @@ +// 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.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileDocumentManagerListener +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +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 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.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString +import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread + +class TextDocumentServiceHandler( + private val project: Project, + serverInstance: Disposable, +) : FileDocumentManagerListener, + FileEditorManagerListener, + BulkFileListener { + + init { + // didOpen & didClose events + project.messageBus.connect(serverInstance).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + this + ) + + // didChange events + project.messageBus.connect(serverInstance).subscribe( + VirtualFileManager.VFS_CHANGES, + this + ) + + // didSave events + project.messageBus.connect(serverInstance).subscribe( + FileDocumentManagerListener.TOPIC, + this + ) + + // open files on startup + val fileEditorManager = FileEditorManager.getInstance(project) + fileEditorManager.openFiles.forEach { file -> + handleFileOpened(file) + } + } + + private fun handleFileOpened(file: VirtualFile) { + AmazonQLspService.executeIfRunning(project) { 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) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeIfRunning + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didSave( + DidSaveTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + text = document.text + } + ) + } + } + } + + override fun after(events: MutableList) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + pluginAwareExecuteOnPooledThread { + events.filterIsInstance().forEach { event -> + val document = FileDocumentManager.getInstance().getCachedDocument(event.file) ?: return@forEach + 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, + ) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + } + ) + } + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt new file mode 100644 index 00000000000..b2821257a49 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt @@ -0,0 +1,42 @@ +// 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.util + +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import java.io.File +import java.net.URI +import java.net.URISyntaxException + +object FileUriUtil { + + fun toUriString(virtualFile: VirtualFile): String? { + val protocol = virtualFile.fileSystem.protocol + val uri = when (protocol) { + "jar" -> VfsUtilCore.convertToURL(virtualFile.url)?.toExternalForm() + "jrt" -> virtualFile.url + else -> toUri(VfsUtilCore.virtualToIoFile(virtualFile)).toASCIIString() + } ?: return null + + return if (virtualFile.isDirectory) { + uri.trimEnd('/', '\\') + } else { + uri + } + } + + private fun toUri(file: File): URI { + try { + // URI scheme specified by language server protocol + return URI("file", "", file.absoluteFile.toURI().path, null) + } catch (e: URISyntaxException) { + LOG.warn { "${e.localizedMessage}: $e" } + return file.absoluteFile.toURI() + } + } + + private val LOG = getLogger() +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt new file mode 100644 index 00000000000..87e570b9f48 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt @@ -0,0 +1,26 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootManager +import org.eclipse.lsp4j.WorkspaceFolder +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString + +object WorkspaceFolderUtil { + fun createWorkspaceFolders(project: Project): List = + if (project.isDefault) { + emptyList() + } else { + ModuleManager.getInstance(project).modules.mapNotNull { module -> + ModuleRootManager.getInstance(module).contentRoots.firstOrNull()?.let { contentRoot -> + WorkspaceFolder().apply { + name = module.name + uri = toUriString(contentRoot) + } + } + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt new file mode 100644 index 00000000000..a457a6e0ef5 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt @@ -0,0 +1,287 @@ +// 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.workspace + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootEvent +import com.intellij.openapi.roots.ModuleRootListener +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.VFileCopyEvent +import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent +import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent +import org.eclipse.lsp4j.CreateFilesParams +import org.eclipse.lsp4j.DeleteFilesParams +import org.eclipse.lsp4j.DidChangeWatchedFilesParams +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.FileChangeType +import org.eclipse.lsp4j.FileCreate +import org.eclipse.lsp4j.FileDelete +import org.eclipse.lsp4j.FileEvent +import org.eclipse.lsp4j.FileRename +import org.eclipse.lsp4j.RenameFilesParams +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.WorkspaceFolder +import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders +import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread +import java.nio.file.FileSystems +import java.nio.file.Paths + +class WorkspaceServiceHandler( + private val project: Project, + serverInstance: Disposable, +) : BulkFileListener, + ModuleRootListener { + + private var lastSnapshot: List = emptyList() + private val supportedFilePatterns = FileSystems.getDefault().getPathMatcher( + "glob:**/*.{ts,js,py,java}" + ) + + init { + project.messageBus.connect(serverInstance).subscribe( + VirtualFileManager.VFS_CHANGES, + this + ) + + project.messageBus.connect(serverInstance).subscribe( + ModuleRootListener.TOPIC, + this + ) + } + + private fun didCreateFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validFiles = events.mapNotNull { event -> + when (event) { + is VFileCopyEvent -> { + val newFile = event.newParent.findChild(event.newChildName)?.takeIf { shouldHandleFile(it) } + ?: return@mapNotNull null + toUriString(newFile)?.let { uri -> + FileCreate().apply { + this.uri = uri + } + } + } + else -> { + val file = event.file?.takeIf { shouldHandleFile(it) } + ?: return@mapNotNull null + toUriString(file)?.let { uri -> + FileCreate().apply { + this.uri = uri + } + } + } + } + } + + if (validFiles.isNotEmpty()) { + languageServer.workspaceService.didCreateFiles( + CreateFilesParams().apply { + files = validFiles + } + ) + } + } + } + + private fun didDeleteFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validFiles = events.mapNotNull { event -> + when (event) { + is VFileDeleteEvent -> { + val file = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + toUriString(file) + } + is VFileMoveEvent -> { + val oldFile = event.oldParent?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + toUriString(oldFile) + } + else -> null + }?.let { uri -> + FileDelete().apply { + this.uri = uri + } + } + } + + if (validFiles.isNotEmpty()) { + languageServer.workspaceService.didDeleteFiles( + DeleteFilesParams().apply { + files = validFiles + } + ) + } + } + } + + private fun didRenameFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validRenames = events + .filter { it.propertyName == VirtualFile.PROP_NAME } + .mapNotNull { event -> + val renamedFile = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + val oldFileName = event.oldValue as? String ?: return@mapNotNull null + val parentFile = renamedFile.parent ?: return@mapNotNull null + + val oldUri = toUriString(parentFile)?.let { parentUri -> "$parentUri/$oldFileName" } + val newUri = toUriString(renamedFile) + + if (!renamedFile.isDirectory) { + oldUri?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + } + ) + } + + newUri?.let { uri -> + languageServer.textDocumentService.didOpen( + DidOpenTextDocumentParams().apply { + textDocument = TextDocumentItem().apply { + this.uri = uri + text = renamedFile.inputStream.readAllBytes().decodeToString() + languageId = renamedFile.fileType.name.lowercase() + version = renamedFile.modificationStamp.toInt() + } + } + ) + } + } + + FileRename().apply { + this.oldUri = oldUri + this.newUri = newUri + } + } + + if (validRenames.isNotEmpty()) { + languageServer.workspaceService.didRenameFiles( + RenameFilesParams().apply { + files = validRenames + } + ) + } + } + } + + private fun didChangeWatchedFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validChanges = events.flatMap { event -> + when (event) { + is VFileCopyEvent -> { + event.newParent.findChild(event.newChildName)?.let { newFile -> + toUriString(newFile)?.let { uri -> + listOf( + FileEvent().apply { + this.uri = uri + type = FileChangeType.Created + } + ) + } + }.orEmpty() + } + is VFileMoveEvent -> { + listOfNotNull( + toUriString(event.oldParent)?.let { oldUri -> + FileEvent().apply { + uri = oldUri + type = FileChangeType.Deleted + } + }, + toUriString(event.file)?.let { newUri -> + FileEvent().apply { + uri = newUri + type = FileChangeType.Created + } + } + ) + } + else -> { + event.file?.let { file -> + toUriString(file)?.let { uri -> + listOf( + FileEvent().apply { + this.uri = uri + type = when (event) { + is VFileCreateEvent -> FileChangeType.Created + is VFileDeleteEvent -> FileChangeType.Deleted + else -> FileChangeType.Changed + } + } + ) + } + }.orEmpty() + } + } + } + + if (validChanges.isNotEmpty()) { + languageServer.workspaceService.didChangeWatchedFiles( + DidChangeWatchedFilesParams().apply { + changes = validChanges + } + ) + } + } + } + + override fun after(events: List) { + // since we are using synchronous FileListener + pluginAwareExecuteOnPooledThread { + didCreateFiles(events.filter { it is VFileCreateEvent || it is VFileMoveEvent || it is VFileCopyEvent }) + didDeleteFiles(events.filter { it is VFileMoveEvent || it is VFileDeleteEvent }) + didRenameFiles(events.filterIsInstance()) + didChangeWatchedFiles(events) + } + } + + override fun beforeRootsChange(event: ModuleRootEvent) { + lastSnapshot = createWorkspaceFolders(project) + } + + override fun rootsChanged(event: ModuleRootEvent) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val currentSnapshot = createWorkspaceFolders(project) + val addedFolders = currentSnapshot.filter { folder -> lastSnapshot.none { it.uri == folder.uri } } + val removedFolders = lastSnapshot.filter { folder -> currentSnapshot.none { it.uri == folder.uri } } + + if (addedFolders.isNotEmpty() || removedFolders.isNotEmpty()) { + languageServer.workspaceService.didChangeWorkspaceFolders( + DidChangeWorkspaceFoldersParams().apply { + this.event = WorkspaceFoldersChangeEvent().apply { + added = addedFolders + removed = removedFolders + } + } + ) + } + + lastSnapshot = currentSnapshot + } + } + + private fun shouldHandleFile(file: VirtualFile): Boolean { + if (file.isDirectory) { + return true // Matches "**/*" with matches: "folder" + } + val path = Paths.get(file.path) + val result = supportedFilePatterns.matches(path) + return result + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QEndpoints.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QEndpoints.kt new file mode 100644 index 00000000000..8119def3686 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QEndpoints.kt @@ -0,0 +1,38 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.profile +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.util.registry.Registry +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn + +object QEndpoints { + private val LOG = getLogger() + data class QRegionEndpoint(val region: String, val endpoint: String) + + object Q_DEFAULT_SERVICE_CONFIG { + const val REGION = "us-east-1" + const val ENDPOINT = "https://codewhisperer.us-east-1.amazonaws.com/" + } + + private fun parseEndpoints(): Map { + val rawJson = Registry.get("amazon.q.endpoints.json").asString().takeIf { it.isNotBlank() } ?: return emptyMap() + return try { + val regionList: List = jacksonObjectMapper().readValue(rawJson) + regionList.associate { it.region to it.endpoint } + } catch (e: Exception) { + LOG.warn(e) { "Failed to parse amazon.q.endpoints.json: $rawJson" } + emptyMap() + } + } + + fun listRegionEndpoints(): List = parseEndpoints().map { (region, endpoint) -> QRegionEndpoint(region, endpoint) } + + fun getQEndpointWithRegion(regionId: String): String { + val all = parseEndpoints() + return all[regionId] + ?: error("No available endpoint for region=$regionId (check amazon.q.endpoints.json or default fallback)") + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QProfileResources.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QProfileResources.kt new file mode 100644 index 00000000000..9f954de1c4e --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QProfileResources.kt @@ -0,0 +1,59 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.profile + +import software.amazon.awssdk.awscore.exception.AwsServiceException +import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClient +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.Resource +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import java.time.Duration + +/** + * Save Amazon Q Profile Resource Cache + */ +object QProfileResources { + /** + * save available Q Profile list as cache with default duration 60 s。 + */ + val LIST_REGION_PROFILES = object : Resource.Cached>() { + override val id: String = "amazonq.allProfiles" + + override fun fetch(connectionSettings: ClientConnectionSettings<*>): List { + val mappedProfiles = QEndpoints.listRegionEndpoints().flatMap { (regionKey, _) -> + val awsRegion = AwsRegionProvider.getInstance()[regionKey] ?: return@flatMap emptyList() + val client = AwsClientManager + .getInstance() + .getClient(connectionSettings.withRegion(awsRegion)) + + try { + val profiles = client.listAvailableProfilesPaginator {} + .profiles() + .map { p -> QRegionProfile(arn = p.arn(), profileName = p.profileName() ?: "") } + LOG.debug { "Found profiles for region $regionKey : $profiles" } + + profiles + } catch (e: Exception) { + LOG.warn(e) { "Failed to list Q profiles for region $regionKey" } + + // service has low TPS so only suppress if not a service error + if (e is AwsServiceException) { + throw e + } + + emptyList() + } + } + return mappedProfiles + } + + override fun expiry(): Duration = Duration.ofSeconds(60) + } + + private val LOG = getLogger() +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QProfileSwitchIntent.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QProfileSwitchIntent.kt new file mode 100644 index 00000000000..c1a7b4627ab --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QProfileSwitchIntent.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.services.amazonq.profile + +/** + * 'user' -> users change the profile through Q menu + * 'auth' -> users change the profile through webview profile selector page + * 'update' -> plugin auto select the profile on users' behalf as there is only 1 profile + * 'reload' -> on plugin restart, plugin will try to reload previous selected profile + */ +enum class QProfileSwitchIntent(val value: String) { + User("user"), + Auth("auth"), + Update("update"), + Reload("reload"), ; + + override fun toString() = value +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfile.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfile.kt new file mode 100644 index 00000000000..e8181d75228 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfile.kt @@ -0,0 +1,24 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.profile +import software.amazon.awssdk.arns.Arn +import software.aws.toolkits.core.utils.tryOrNull + +data class QRegionProfile( + var profileName: String = "", + var arn: String = "", +) { + private val parsedArn: Arn? by lazy { + tryOrNull { + Arn.fromString(arn) + } + } + val accountId: String by lazy { + parsedArn?.accountId()?.get().orEmpty() + } + + val region: String by lazy { + parsedArn?.region()?.get().orEmpty() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileDialog.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileDialog.kt new file mode 100644 index 00000000000..c0921f3362f --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileDialog.kt @@ -0,0 +1,143 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.profile + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toNullableProperty +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.ui.AsyncComboBox +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.AmazonQBundle.message +import software.aws.toolkits.resources.AwsCoreBundle +import software.aws.toolkits.telemetry.MetricResult +import software.aws.toolkits.telemetry.Telemetry +import javax.swing.JComponent +import javax.swing.JList + +data class QRegionProfileDialogState( + var selectedProfile: QRegionProfile? = null, +) + +class QRegionProfileDialog( + private var project: Project, + val state: QRegionProfileDialogState = QRegionProfileDialogState(), + private var selectedProfile: QRegionProfile?, +) : DialogWrapper(project) { + + private val renderer = object : ColoredListCellRenderer() { + override fun customizeCellRenderer( + list: JList, + value: QRegionProfile?, + index: Int, + selected: Boolean, + hasFocus: Boolean, + ) { + value?.let { + append( + if (it == selectedProfile) { + "${it.profileName} - ${it.region} (connected)" + } else { + "${it.profileName} - ${it.region}" + }, + SimpleTextAttributes.REGULAR_ATTRIBUTES + ) + + append(" " + message("action.q.switchProfiles.dialog.account.label", it.accountId), SimpleTextAttributes.GRAY_SMALL_ATTRIBUTES) + } + } + } + + private val combo = AsyncComboBox(customRenderer = renderer) + + private val panel: DialogPanel by lazy { + panel { + row { label(message("action.q.switchProfiles.dialog.panel.text")).bold() } + .bottomGap(BottomGap.MEDIUM) + row { text(message("action.q.switchProfiles.dialog.panel.description")) } + row { + icon(AllIcons.General.Warning) + text(message("action.q.switchProfiles.dialog.panel.warning")) + } + separator().bottomGap(BottomGap.MEDIUM) + + combo.proposeModelUpdate { model -> + try { + QRegionProfileManager.getInstance().listRegionProfiles(project)?.forEach { + model.addElement(it) + } ?: error("Attempted to fetch profiles while there does not exist") + + model.selectedItem = selectedProfile + } catch (e: Exception) { + val conn = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) as? AwsBearerTokenConnection + Telemetry.amazonq.didSelectProfile.use { span -> + span.source(QProfileSwitchIntent.User.value) + .amazonQProfileRegion(QRegionProfileManager.getInstance().activeProfile(project)?.region ?: "not-set") + .ssoRegion(conn?.region) + .credentialStartUrl(conn?.startUrl) + .result(MetricResult.Failed) + .reason(e.message) + } + throw e + } + } + + row { + cell(combo) + .align(AlignX.FILL) + .errorOnApply(AwsCoreBundle.message("gettingstarted.setup.error.not_selected")) { it.selected() == null } + .bindItem(state::selectedProfile.toNullableProperty()) + } + + separator().bottomGap(BottomGap.MEDIUM) + } + } + + private val selectedOption + get() = state.selectedProfile // user selected + + init { + title = message("action.q.switchProfiles.dialog.text") + setOKButtonText(message("general.ok")) + setCancelButtonText(message("general.cancel")) + init() + } + + override fun getHelpId(): String = HelpIds.Q_SWITCH_PROFILES_DIALOG.id + override fun createCenterPanel(): JComponent = panel + override fun doOKAction() { + panel.apply() + if (selectedOption != selectedProfile) { + QRegionProfileManager.getInstance().switchProfile(project, selectedOption, intent = QProfileSwitchIntent.User) + } + close(OK_EXIT_CODE) + } + + override fun doCancelAction() { + super.doCancelAction() + val profileManager = QRegionProfileManager.getInstance() + val conn = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) as? AwsBearerTokenConnection + Telemetry.amazonq.didSelectProfile.use { span -> + span.source(QProfileSwitchIntent.User.value) + .amazonQProfileRegion(profileManager.activeProfile(project)?.region ?: "not-set") + .profileCount(combo.model.size) + .ssoRegion(conn?.region) + .credentialStartUrl(conn?.startUrl) + .result(MetricResult.Cancelled) + } + + close(CANCEL_EXIT_CODE) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileManager.kt new file mode 100644 index 00000000000..b3b242c363d --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileManager.kt @@ -0,0 +1,237 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.profile + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.xmlb.annotations.MapAnnotation +import com.intellij.util.xmlb.annotations.Property +import software.amazon.awssdk.core.SdkClient +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.utils.debug +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.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +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.isSono +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.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.AmazonQBundle.message +import software.aws.toolkits.telemetry.MetricResult +import software.aws.toolkits.telemetry.Telemetry +import java.time.Duration +import java.util.Collections +import kotlin.reflect.KClass + +@Service(Service.Level.APP) +@State(name = "qProfileStates", storages = [Storage("aws.xml")]) +class QRegionProfileManager : PersistentStateComponent, Disposable { + + // Map to store connectionId to its active profile + private val connectionIdToActiveProfile = Collections.synchronizedMap(mutableMapOf()) + private val connectionIdToProfileCount = mutableMapOf() + + init { + ApplicationManager.getApplication().messageBus.connect(this) + .subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun invalidate(providerId: String) { + connectionIdToActiveProfile.remove(providerId) + connectionIdToProfileCount.remove(providerId) + } + } + ) + } + + // should be call on project startup to validate if profile is still active + @RequiresBackgroundThread + fun validateProfile(project: Project) { + val conn = getIdcConnectionOrNull(project) + val selected = activeProfile(project) ?: return + val profiles = tryOrNull { + listRegionProfiles(project) + } + + if (profiles == null || profiles.none { it.arn == selected.arn }) { + invalidateProfile(selected.arn) + switchProfile(project, null, intent = QProfileSwitchIntent.Reload) + Telemetry.amazonq.profileState.use { span -> + span.source(QProfileSwitchIntent.Reload.value) + .amazonQProfileRegion(selected.region) + .ssoRegion(conn?.region) + .credentialStartUrl(conn?.startUrl) + .result(MetricResult.Failed) + } + } + } + + fun listRegionProfiles(project: Project): List? { + val connection = getIdcConnectionOrNull(project) ?: return null + return try { + val connectionSettings = connection.getConnectionSettings() + val mappedProfiles = AwsResourceCache.getInstance().getResourceNow( + resource = QProfileResources.LIST_REGION_PROFILES, + connectionSettings = connectionSettings, + timeout = Duration.ofSeconds(30), + useStale = true, + forceFetch = false + ) + if (mappedProfiles.size == 1) { + switchProfile(project, mappedProfiles.first(), intent = QProfileSwitchIntent.Update) + } + mappedProfiles.takeIf { it.isNotEmpty() }?.also { + connectionIdToProfileCount[connection.id] = it.size + } ?: error("You don't have access to the resource") + } catch (e: Exception) { + LOG.warn(e) { "Failed to list region profiles: ${e.message}" } + throw e + } + } + + fun activeProfile(project: Project): QRegionProfile? = getIdcConnectionOrNull(project)?.let { connectionIdToActiveProfile[it.id] } + + fun hasValidConnectionButNoActiveProfile(project: Project): Boolean = getIdcConnectionOrNull(project) != null && activeProfile(project) == null + + fun switchProfile(project: Project, newProfile: QRegionProfile?, intent: QProfileSwitchIntent) { + val conn = getIdcConnectionOrNull(project) ?: return + + val oldProfile = connectionIdToActiveProfile[conn.id] + if (oldProfile == newProfile) return + + connectionIdToActiveProfile[conn.id] = newProfile + LOG.debug { "Switch from profile $oldProfile to $newProfile for project ${project.name}" } + + if (newProfile != null) { + if (intent == QProfileSwitchIntent.User || intent == QProfileSwitchIntent.Auth) { + notifyInfo( + title = message("action.q.profile.usage.text"), + content = message("action.q.profile.usage", newProfile.profileName), + project = project + ) + + Telemetry.amazonq.didSelectProfile.use { span -> + span.source(intent.value) + .amazonQProfileRegion(newProfile.region) + .profileCount(connectionIdToProfileCount[conn.id]) + .ssoRegion(conn.region) + .credentialStartUrl(conn.startUrl) + .result(MetricResult.Succeeded) + } + } else { + Telemetry.amazonq.profileState.use { span -> + span.source(intent.value) + .amazonQProfileRegion(newProfile.region) + .ssoRegion(conn.region) + .credentialStartUrl(conn.startUrl) + .result(MetricResult.Succeeded) + } + } + } + + ApplicationManager.getApplication().messageBus + .syncPublisher(QRegionProfileSelectedListener.TOPIC) + .onProfileSelected(project, newProfile) + } + + private fun invalidateProfile(arn: String) { + val updated = connectionIdToActiveProfile.filterValues { it.arn != arn } + connectionIdToActiveProfile.clear() + connectionIdToActiveProfile.putAll(updated) + } + + // for each idc connection, user should have a profile, otherwise should show the profile selection error page + fun isPendingProfileSelection(project: Project): Boolean = getIdcConnectionOrNull(project)?.let { conn -> + val profileCounts = connectionIdToProfileCount[conn.id] ?: 0 + val activeProfile = connectionIdToActiveProfile[conn.id] + profileCounts == 0 || (profileCounts > 1 && activeProfile?.arn.isNullOrEmpty()) + } ?: false + + fun shouldDisplayProfileInfo(project: Project): Boolean = getIdcConnectionOrNull(project)?.let { conn -> + (connectionIdToProfileCount[conn.id] ?: 0) > 1 + } ?: false + + fun getQClientSettings(project: Project): TokenConnectionSettings { + val conn = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + if (conn !is AwsBearerTokenConnection) { + error("not a bearer connection") + } + + val settings = conn.getConnectionSettings() + val awsRegion = AwsRegionProvider.getInstance()[QEndpoints.Q_DEFAULT_SERVICE_CONFIG.REGION] ?: error("unknown region from Q default service config") + + // TODO: different window should be able to select different profile + return activeProfile(project)?.let { profile -> + AwsRegionProvider.getInstance()[profile.region]?.let { region -> + settings.withRegion(region) + } + } ?: settings.withRegion(awsRegion) + } + + inline fun getQClient(project: Project): T = getQClient(project, T::class) + + fun getQClient(project: Project, sdkClass: KClass): T { + val settings = getQClientSettings(project) + val client = AwsClientManager.getInstance().getClient(sdkClass, settings) + return client + } + + fun getIdcConnectionOrNull(project: Project): AwsBearerTokenConnection? { + val manager = ToolkitConnectionManager.getInstance(project) + val connection = manager.activeConnectionForFeature(QConnection.getInstance()) as? AwsBearerTokenConnection + val state = manager.connectionStateForFeature(QConnection.getInstance()) + + return if (connection != null && !connection.isSono() && state == BearerTokenAuthState.AUTHORIZED) { + connection + } else { + null + } + } + + companion object { + private val LOG = getLogger() + fun getInstance(): QRegionProfileManager = service() + } + + override fun dispose() {} + + override fun getState(): QProfileState { + val state = QProfileState() + state.connectionIdToActiveProfile.putAll(this.connectionIdToActiveProfile) + state.connectionIdToProfileList.putAll(this.connectionIdToProfileCount) + return state + } + + override fun loadState(state: QProfileState) { + connectionIdToActiveProfile.clear() + connectionIdToActiveProfile.putAll(state.connectionIdToActiveProfile) + + connectionIdToProfileCount.clear() + connectionIdToProfileCount.putAll(state.connectionIdToProfileList) + } +} + +class QProfileState : BaseState() { + @get:Property + @get:MapAnnotation + val connectionIdToActiveProfile by map() + + @get:Property + @get:MapAnnotation + val connectionIdToProfileList by map() +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileSelectedListener.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileSelectedListener.kt new file mode 100644 index 00000000000..e507cd04a13 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/profile/QRegionProfileSelectedListener.kt @@ -0,0 +1,16 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.profile + +import com.intellij.openapi.project.Project +import com.intellij.util.messages.Topic + +interface QRegionProfileSelectedListener { + companion object { + @Topic.AppLevel + val TOPIC = Topic.create("QRegionProfileSelected", QRegionProfileSelectedListener::class.java) + } + + fun onProfileSelected(project: Project, profile: QRegionProfile?) +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt index 5b61aedf94d..2da14c2dd50 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt @@ -21,15 +21,13 @@ import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import org.apache.commons.codec.digest.DigestUtils import software.amazon.awssdk.utils.UserHomeDirectoryUtils -import software.aws.toolkits.core.utils.createParentDirectories -import software.aws.toolkits.core.utils.exists import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.tryDirOp import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.extractZipFile import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings -import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files import java.nio.file.Path @@ -39,7 +37,6 @@ import java.security.Key import java.security.SecureRandom import java.util.Base64 import java.util.concurrent.atomic.AtomicInteger -import java.util.zip.ZipFile import javax.crypto.spec.SecretKeySpec class EncoderServer(val project: Project) : Disposable { @@ -183,7 +180,7 @@ class EncoderServer(val project: Project) : Disposable { if (serverContent?.url != null) { if (validateHash(serverContent.hashes?.first(), HttpRequests.request(serverContent.url).readBytes(null))) { downloadFromRemote(serverContent.url, zipFilePath) - unzipFile(zipFilePath, cachePath) + extractZipFile(zipFilePath, cachePath) } } } catch (e: Exception) { @@ -231,26 +228,6 @@ class EncoderServer(val project: Project) : Disposable { Files.setPosixFilePermissions(filePath, permissions) } - private fun unzipFile(zipFilePath: Path, destDir: Path) { - if (!zipFilePath.exists()) return - try { - val zipFile = ZipFile(zipFilePath.toFile()) - zipFile.use { file -> - file.entries().asSequence() - .filterNot { it.isDirectory } - .map { zipEntry -> - val destPath = destDir.resolve(zipEntry.name) - destPath.createParentDirectories() - FileOutputStream(destPath.toFile()).use { targetFile -> - zipFile.getInputStream(zipEntry).copyTo(targetFile) - } - }.toList() - } - } catch (e: Exception) { - logger.warn { "error while unzipping project context artifact: ${e.message}" } - } - } - private fun downloadFromRemote(url: String, path: Path) { try { HttpRequests.request(url).saveToFile(path, null) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/FeatureDevSessionContext.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/FeatureDevSessionContext.kt index e02e1d5bd7a..974ac171b6a 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/FeatureDevSessionContext.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/FeatureDevSessionContext.kt @@ -18,6 +18,7 @@ import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.io.FileUtils import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext import software.aws.toolkits.jetbrains.services.amazonq.QConstants.MAX_FILE_SIZE_BYTES +import software.aws.toolkits.jetbrains.utils.getWorkspaceDevFile import software.aws.toolkits.jetbrains.utils.isWorkspaceDevFile import software.aws.toolkits.resources.AwsCoreBundle import software.aws.toolkits.telemetry.AmazonqTelemetry @@ -57,11 +58,8 @@ open class FeatureDevSessionContext(val project: Project, val maxProjectSizeByte private var _selectionRoot = workspaceRoot - // This function checks for existence of `devfile.yaml` in customer's repository, currently only `devfile.yaml` is supported for this feature. - fun checkForDevFile(): Boolean { - val devFile = File(addressableRoot.toString(), "devfile.yaml") - return devFile.exists() - } + fun hasDevFile(): Boolean = + getWorkspaceDevFile(addressableRoot) != null fun getProjectZip(isAutoBuildFeatureEnabled: Boolean?): ZipCreationResult { val zippedProject = runBlocking { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt index cb9d8b9362a..854124a180f 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt @@ -70,12 +70,12 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En data class FileCollectionResult( val files: List, - val fileSize: Int, + val fileSize: Int, // in MB ) // TODO: move to LspMessage.kt + @JsonIgnoreProperties(ignoreUnknown = true) data class Usage( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("memoryUsage") val memoryUsage: Int? = null, @JsonProperty("cpuUsage") @@ -83,8 +83,8 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En ) // TODO: move to LspMessage.kt + @JsonIgnoreProperties(ignoreUnknown = true) data class Chunk( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("filePath") val filePath: String? = null, @JsonProperty("content") @@ -116,10 +116,14 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En logger.info { "project context index starting" } delay(300) val isIndexSuccess = index() - if (isIndexSuccess) isIndexComplete.set(true) + if (isIndexSuccess) { + isIndexComplete.set(true) + } return } + retryCount.incrementAndGet() } catch (e: Exception) { + logger.warn(e) { "failed to init project context" } if (e.stackTraceToString().contains("Connection refused")) { retryCount.incrementAndGet() delay(10000) @@ -133,6 +137,7 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En private suspend fun initEncryption(): Boolean { val request = encoderServer.getEncryptionRequest() val response = sendMsgToLsp(LspMessage.Initialize, request) + logger.info { "received response to init encryption: $response" } return response?.responseCode == 200 } @@ -241,59 +246,7 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En } } - private fun willExceedPayloadLimit(currentTotalFileSize: Long, currentFileSize: Long): Boolean { - val maxSize = CodeWhispererSettings.getInstance().getProjectContextIndexMaxSize() - return currentTotalFileSize.let { totalSize -> totalSize > (maxSize * 1024 * 1024 - currentFileSize) } - } - - private fun isBuildOrBin(fileName: String): Boolean { - val regex = Regex("""bin|build|node_modules|venv|\.venv|env|\.idea|\.conda""", RegexOption.IGNORE_CASE) - return regex.find(fileName) != null - } - - fun collectFiles(): FileCollectionResult { - val collectedFiles = mutableListOf() - var currentTotalFileSize = 0L - val allFiles = mutableListOf() - - val projectBaseDirectories = project.getBaseDirectories() - val changeListManager = ChangeListManager.getInstance(project) - - projectBaseDirectories.forEach { - VfsUtilCore.visitChildrenRecursively( - it, - object : VirtualFileVisitor(NO_FOLLOW_SYMLINKS) { - // TODO: refactor this along with /dev & codescan file traversing logic - override fun visitFile(file: VirtualFile): Boolean { - if ((file.isDirectory && isBuildOrBin(file.name)) || - !isWorkspaceSourceContent(file, projectBaseDirectories, changeListManager, additionalGlobalIgnoreRulesForStrictSources) || - (file.isFile && file.length > 10 * 1024 * 1024) - ) { - return false - } - if (file.isFile) { - allFiles.add(file) - return false - } - return true - } - } - ) - } - - for (file in allFiles) { - if (willExceedPayloadLimit(currentTotalFileSize, file.length)) { - break - } - collectedFiles.add(file.path) - currentTotalFileSize += file.length - } - - return FileCollectionResult( - files = collectedFiles.toList(), - fileSize = (currentTotalFileSize / 1024 / 1024).toInt() - ) - } + fun collectFiles(): FileCollectionResult = collectFiles(project.getBaseDirectories(), ChangeListManager.getInstance(project)) private fun queryResultToRelevantDocuments(queryResult: List): List { val documents: MutableList = mutableListOf() @@ -315,12 +268,12 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En } private suspend fun sendMsgToLsp(msgType: LspMessage, request: String?): LspResponse? { - logger.info { "sending message: ${msgType.endpoint} to lsp on port ${encoderServer.port}" } - val url = URI("http://127.0.0.1:${encoderServer.port}/${msgType.endpoint}").toURL() if (!encoderServer.isNodeProcessRunning()) { logger.warn { "language server for ${project.name} is not running" } return null } + logger.info { "sending message: ${msgType.endpoint} to lsp on port ${encoderServer.port}" } + val url = URI("http://127.0.0.1:${encoderServer.port}/${msgType.endpoint}").toURL() // use 1h as timeout for index, 5 seconds for other APIs val timeoutMs = if (msgType is LspMessage.Index) 60.minutes.inWholeMilliseconds.toInt() else 5000 // dedicate single thread to index operation because it can be long running @@ -353,5 +306,57 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En companion object { private val logger = getLogger() + private val regex = Regex("""bin|build|node_modules|venv|\.venv|env|\.idea|\.conda""", RegexOption.IGNORE_CASE) + private val mega = (1024 * 1024).toULong() + private val tenMb = 10 * mega.toInt() + + private fun willExceedPayloadLimit(maxSize: ULong, currentTotalFileSize: ULong, currentFileSize: Long) = + currentTotalFileSize.let { totalSize -> totalSize > (maxSize - currentFileSize.toUInt()) } + + private fun isBuildOrBin(fileName: String): Boolean = + regex.find(fileName) != null + + fun collectFiles(projectBaseDirectories: Set, changeListManager: ChangeListManager): FileCollectionResult { + val maxSize = CodeWhispererSettings.getInstance() + .getProjectContextIndexMaxSize().toULong() * mega + val collectedFiles = mutableListOf() + var currentTotalFileSize = 0UL + val allFiles = mutableListOf() + + projectBaseDirectories.forEach { + VfsUtilCore.visitChildrenRecursively( + it, + object : VirtualFileVisitor(NO_FOLLOW_SYMLINKS) { + // TODO: refactor this along with /dev & codescan file traversing logic + override fun visitFile(file: VirtualFile): Boolean { + if ((file.isDirectory && isBuildOrBin(file.name)) || + !isWorkspaceSourceContent(file, projectBaseDirectories, changeListManager, additionalGlobalIgnoreRulesForStrictSources) || + (file.isFile && file.length > tenMb) + ) { + return false + } + if (file.isFile) { + allFiles.add(file) + return false + } + return true + } + } + ) + } + + for (file in allFiles) { + if (willExceedPayloadLimit(maxSize, currentTotalFileSize, file.length)) { + break + } + collectedFiles.add(file.path) + currentTotalFileSize += file.length.toUInt() + } + + return FileCollectionResult( + files = collectedFiles.toList(), + fileSize = (currentTotalFileSize / 1024u / 1024u).toInt() + ) + } } } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt index 52b95acf976..484c319db52 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt @@ -3,8 +3,8 @@ package software.aws.toolkits.jetbrains.services.amazonq.project.manifest -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.openapi.util.SystemInfo @@ -15,13 +15,12 @@ import software.aws.toolkits.jetbrains.core.getTextFromUrl class ManifestManager { private val cloudFrontUrl = "https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json" - val currentVersion = "0.1.46" + val currentVersion = "0.1.49" val currentOs = getOs() private val arch = CpuArch.CURRENT - private val mapper = jacksonObjectMapper() + private val mapper = jacksonObjectMapper().apply { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) } data class TargetContent( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("filename") val filename: String? = null, @JsonProperty("url") @@ -33,7 +32,6 @@ class ManifestManager { ) data class VersionTarget( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("platform") val platform: String? = null, @JsonProperty("arch") @@ -41,8 +39,8 @@ class ManifestManager { @JsonProperty("contents") val contents: List? = emptyList(), ) + data class Version( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("serverVersion") val serverVersion: String? = null, @JsonProperty("isDelisted") @@ -52,7 +50,6 @@ class ManifestManager { ) data class Manifest( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("manifestSchemaVersion") val manifestSchemaVersion: String? = null, @JsonProperty("artifactId") @@ -67,7 +64,7 @@ class ManifestManager { fun getManifest(): Manifest? = fetchFromRemoteAndSave() - private fun readManifestFile(content: String): Manifest? { + fun readManifestFile(content: String): Manifest? { try { return mapper.readValue(content) } catch (e: Exception) { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt similarity index 91% rename from plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt rename to plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt index a4847345d99..4385555c01d 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt @@ -21,6 +21,6 @@ object CodeWhispererColorUtil { val EDITOR_CODE_REFERENCE_HOVER = JBColor(0x4B4D4D, 0x4B4D4D) val INACTIVE_TEXT_COLOR = UIUtil.getInactiveTextColor().getHexString() val TRY_EXAMPLE_EVEN_ROW_COLOR = JBColor(0xCACACA, 0x252525) - - fun Color.getHexString() = String.format("#%02x%02x%02x", this.red, this.green, this.blue) } + +fun Color.getHexString() = String.format(null, "#%02x%02x%02x", this.red, this.green, this.blue) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt index 45b96113dcc..2c5cac62e57 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt @@ -10,7 +10,9 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service +import com.intellij.openapi.project.ProjectManager import com.intellij.util.xmlb.annotations.Property +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.utils.notifyInfo import software.aws.toolkits.resources.AmazonQBundle @@ -21,6 +23,13 @@ class CodeWhispererSettings : PersistentStateComponent { + private var state = LspConfiguration() + + override fun getState(): LspConfiguration = state + + override fun loadState(state: LspConfiguration) { + this.state = state + } + + fun getArtifactPath() = state.artifactPath + + fun setArtifactPath(artifactPath: String?) { + state.artifactPath = artifactPath.nullize(nullizeSpaces = true) + } + + companion object { + fun getInstance(): LspSettings = service() + } +} + +class LspConfiguration : BaseState() { + var artifactPath by string() +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt new file mode 100644 index 00000000000..83131a45cc9 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt @@ -0,0 +1,135 @@ +// 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.google.gson.Gson +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.ConfigurationItem +import org.eclipse.lsp4j.ConfigurationParams +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +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.model.aws.credentials.ConnectionMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings + +@ExtendWith(ApplicationExtension::class) +class AmazonQLanguageClientImplTest { + private val project: Project = mockk(relaxed = true) + private val sut = AmazonQLanguageClientImpl(project) + + @Test + fun `getConnectionMetadata returns connection metadata with start URL for bearer token connection`() { + val mockConnectionManager = mockk() + every { project.service() } returns mockConnectionManager + + val expectedStartUrl = "https://test.aws.com" + val mockConnection = mockk { + every { startUrl } returns expectedStartUrl + } + + every { mockConnectionManager.activeConnectionForFeature(QConnection.getInstance()) } returns mockConnection + + assertThat(sut.getConnectionMetadata().get()) + .isEqualTo(ConnectionMetadata(SsoProfileData(expectedStartUrl))) + } + + @Test + fun `getConnectionMetadata returns empty start URL when no active connection`() { + val mockConnectionManager = mockk() + every { project.service() } returns mockConnectionManager + + every { mockConnectionManager.activeConnectionForFeature(QConnection.getInstance()) } returns null + + assertThat(sut.getConnectionMetadata().get()) + .isEqualTo(ConnectionMetadata(SsoProfileData(AmazonQLspConstants.AWS_BUILDER_ID_URL))) + } + + @Test + fun `configuration null if no attributes requested`() { + assertThat(sut.configuration(configurationParams()).get()).isNull() + } + + @Test + fun `configuration for codeWhisperer respects opt-out`() { + CodeWhispererSettings.getInstance().toggleMetricOptIn(false) + CodeWhispererSettings.getInstance().toggleWorkspaceContextEnabled(true) + assertThat(sut.configuration(configurationParams("aws.codeWhisperer")).get()) + .singleElement() + .isEqualTo( + CodeWhispererLspConfiguration( + shouldShareData = false, + shouldShareCodeReferences = false, + shouldEnableWorkspaceContext = true + ) + ) + } + + @Test + fun `configuration for codeWhisperer respects opt-in`() { + CodeWhispererSettings.getInstance().toggleMetricOptIn(true) + CodeWhispererSettings.getInstance().toggleWorkspaceContextEnabled(true) + assertThat(sut.configuration(configurationParams("aws.codeWhisperer")).get()) + .singleElement() + .isEqualTo( + CodeWhispererLspConfiguration( + shouldShareData = true, + shouldShareCodeReferences = false, + shouldEnableWorkspaceContext = true + ) + ) + } + + @Test + fun `configuration for workspace context respects opt-in`() { + CodeWhispererSettings.getInstance().toggleWorkspaceContextEnabled(false) + assertThat(sut.configuration(configurationParams("aws.codeWhisperer")).get()) + .singleElement() + .isEqualTo( + CodeWhispererLspConfiguration( + shouldShareData = true, + shouldShareCodeReferences = false, + shouldEnableWorkspaceContext = false + ) + ) + } + + @Test + fun `configuration empty if attributes unknown`() { + CodeWhispererSettings.getInstance().toggleMetricOptIn(true) + assertThat(sut.configuration(configurationParams("something random")).get()).isEmpty() + } + + @Test + fun `Gson serializes CodeWhispererLspConfiguration serializes correctly`() { + val sut = CodeWhispererLspConfiguration( + shouldShareData = true, + shouldShareCodeReferences = true + ) + assertThat(Gson().toJson(sut)).isEqualToIgnoringWhitespace( + """ + { + "shareCodeWhispererContentWithAWS": true, + "includeSuggestionsWithCodeReferences": true + } + """.trimIndent() + ) + } + + private fun configurationParams(vararg attributes: String) = ConfigurationParams( + attributes.map { + ConfigurationItem().apply { + section = it + } + } + ) +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt new file mode 100644 index 00000000000..5fede85b27e --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt @@ -0,0 +1,237 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ApplicationExtension +import com.intellij.util.io.createDirectories +import com.intellij.util.text.SemVer +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.mock +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +@ExtendWith(ApplicationExtension::class) +class ArtifactHelperTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var artifactHelper: ArtifactHelper + private lateinit var manifestVersionRanges: SupportedManifestVersionRange + private lateinit var mockManifestManager: ManifestManager + private lateinit var contents: List + private lateinit var mockProject: Project + + @BeforeEach + fun setUp() { + artifactHelper = ArtifactHelper(tempDir, 3) + mockManifestManager = mock() + contents = listOf( + ManifestManager.TargetContent( + filename = "server.zip", + hashes = listOf("sha384:1234") + ) + ) + mockProject = mockk(relaxed = true) { + every { basePath } returns tempDir.toString() + every { name } returns "TestProject" + } + } + + @Test + fun `removeDelistedVersions removes specified versions`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + val version2Dir = tempDir.resolve("2.0.0").apply { toFile().mkdirs() } + + val delistedVersions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + + artifactHelper.removeDelistedVersions(delistedVersions) + + assertThat(version1Dir.toFile().exists()).isFalse() + assertThat(version2Dir.toFile().exists()).isTrue() + } + + @Test + fun `deleteOlderLspArtifacts should not delete if there are only two version`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + val version2Dir = tempDir.resolve("1.0.1").apply { toFile().mkdirs() } + + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) + + assertThat(version1Dir.toFile().exists()).isTrue() + assertThat(version2Dir.toFile().exists()).isTrue() + } + + @Test + fun `deleteOlderLspArtifacts should delete if there are more than two versions`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + val version2Dir = tempDir.resolve("1.0.1").apply { toFile().mkdirs() } + val version3Dir = tempDir.resolve("1.0.2").apply { toFile().mkdirs() } + + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) + + assertThat(version1Dir.toFile().exists()).isFalse() + assertThat(version2Dir.toFile().exists()).isTrue() + assertThat(version3Dir.toFile().exists()).isTrue() + } + + @Test + fun `getAllLocalLspArtifactsWithinManifestRange should return matching folder path`() { + tempDir.resolve("1.0.0").createDirectories() + tempDir.resolve("1.0.1").createDirectories() + tempDir.resolve("1.0.2").createDirectories() + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + val actualResult = artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges) + assertThat(actualResult).isNotNull() + assertThat(actualResult.size).isEqualTo(3) + assertThat(actualResult.first().first.fileName.toString()).isEqualTo("1.0.2") + } + + @Test + fun `getExistingLspArtifacts should find all the artifacts`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + + val serverZipPath = version1Dir.resolve("server.zip") + serverZipPath.parent.toFile().mkdirs() + serverZipPath.toFile().createNewFile() + + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + + val target = ManifestManager.VersionTarget(contents = contents) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1234" + + val result = artifactHelper.getExistingLspArtifacts(versions, target) + + assertThat(result).isTrue() + assertThat(serverZipPath.toFile().exists()).isTrue() + version1Dir.toFile().deleteRecursively() + } + + @Test + fun `getExistingLspArtifacts should return false due to hash mismatch`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + + val serverZipPath = version1Dir.resolve("server.zip") + serverZipPath.parent.toFile().mkdirs() + serverZipPath.toFile().createNewFile() + + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + + val target = ManifestManager.VersionTarget(contents = contents) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1235" + + val result = artifactHelper.getExistingLspArtifacts(versions, target) + + assertThat(result).isFalse() + assertThat(serverZipPath.toFile().exists()).isFalse() + } + + @Test + fun `getExistingLspArtifacts should return false if versions are empty`() { + val versions = emptyList() + assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse() + } + + @Test + fun `getExistingLspArtifacts should return false if target does not have contents`() { + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse() + } + + @Test + fun `getExistingLspArtifacts should return false if Lsp path does not exist`() { + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse() + } + + @Test + fun `tryDownloadLspArtifacts should not download artifacts if target does not have contents`() { + val versions = listOf(ManifestManager.Version(serverVersion = "2.0.0")) + assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, null) }).isEqualTo(null) + assertThat(tempDir.resolve("2.0.0").toFile().exists()).isFalse() + } + + @Test + fun `tryDownloadLspArtifacts should throw error if failed to download`() { + val versions = listOf(ManifestManager.Version(serverVersion = "1.0.0")) + + val spyArtifactHelper = spyk(artifactHelper) + every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns false + + assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, null) }).isEqualTo(null) + } + + @Test + fun `tryDownloadLspArtifacts should throw error after attempts are exhausted`() { + val versions = listOf(ManifestManager.Version(serverVersion = "1.0.0")) + val target = ManifestManager.VersionTarget(contents = contents) + val spyArtifactHelper = spyk(artifactHelper) + + every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns true + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { moveFilesFromSourceToDestination(any(), any()) } just Runs + every { extractZipFile(any(), any()) } just Runs + + assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, target) }).isEqualTo(null) + } + + @Test + fun `validateFileHash should return false if expected hash is null`() { + assertThat(artifactHelper.validateFileHash(tempDir, null)).isFalse() + } + + @Test + fun `validateFileHash should return false if hash did not match`() { + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1234" + assertThat(artifactHelper.validateFileHash(tempDir, "1234")).isFalse() + } + + @Test + fun `validateFileHash should return true if hash matched`() { + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1234" + assertThat(artifactHelper.validateFileHash(tempDir, "sha384:1234")).isTrue() + } +} 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/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt new file mode 100644 index 00000000000..4e163900bff --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.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.services.amazonq.lsp.artifacts + +import com.intellij.openapi.project.Project +import com.intellij.util.text.SemVer +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.jetbrains.annotations.TestOnly +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +@TestOnly +class ArtifactManagerTest { + + @TempDir + lateinit var tempDir: Path + + private lateinit var artifactHelper: ArtifactHelper + private lateinit var artifactManager: ArtifactManager + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifestVersionRanges: SupportedManifestVersionRange + private lateinit var mockProject: Project + + @BeforeEach + fun setUp() { + 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) + ) + mockProject = mockk(relaxed = true) { + every { basePath } returns tempDir.toString() + every { name } returns "TestProject" + } + artifactManager = ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges) + } + + @Test + fun `fetch artifact fetcher throws exception if manifest is null`() { + every { manifestFetcher.fetch() }.returns(null) + + assertThatThrownBy { + runBlocking { artifactManager.fetchArtifact() } + } + .isInstanceOf(LspException::class.java) + .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.MANIFEST_FETCH_FAILED) + } + + @Test + fun `fetch artifact does not have any valid lsp versions`() { + every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest()) + artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges)) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList()) + ) + + assertThatThrownBy { + runBlocking { artifactManager.fetchArtifact() } + } + .isInstanceOf(LspException::class.java) + .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION) + } + + @Test + fun `fetch artifact if inRangeVersions are not available should fallback to local lsp`() { + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest()) + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + runBlocking { artifactManager.fetchArtifact() } + + verify(exactly = 1) { manifestFetcher.fetch() } + verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) } + } + + @Test + fun `fetch artifact have valid version in local system`() { + val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target))) + + artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges)) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(ManifestManager.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 + + runBlocking { artifactManager.fetchArtifact() } + + verify(exactly = 1) { runBlocking { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } + + @Test + fun `fetch artifact does not have valid version in local system`() { + val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target))) + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges)) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(ManifestManager.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) + + runBlocking { artifactManager.fetchArtifact() } + + verify(exactly = 0) { runBlocking { 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/LspUtilsTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt new file mode 100644 index 00000000000..607b94f34bf --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt @@ -0,0 +1,103 @@ +// 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.testFramework.utils.io.createDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX +import software.aws.toolkits.core.utils.hasPosixFilePermissions +import software.aws.toolkits.core.utils.putNextEntry +import software.aws.toolkits.core.utils.test.assertPosixPermissions +import software.aws.toolkits.core.utils.writeText +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions +import java.util.zip.ZipOutputStream +import kotlin.io.path.isRegularFile +import kotlin.io.path.setPosixFilePermissions + +class LspUtilsTest { + @Test + fun `extractZipFile works`(@TempDir tempDir: Path) { + val source = tempDir.resolve("source").also { it.createDirectory() } + val target = tempDir.resolve("target").also { it.createDirectory() } + + source.resolve("file1").writeText("contents1") + source.resolve("file2").writeText("contents2") + source.resolve("file3").writeText("contents3") + + val sourceZip = tempDir.resolve("source.zip") + ZipOutputStream(Files.newOutputStream(sourceZip)).use { zip -> + Files.walk(source).use { paths -> + paths + .filter { it.isRegularFile() } + .forEach { + zip.putNextEntry(source.relativize(it).toString(), it) + } + val precedingSlashFile = source.resolve("file4").also { it.writeText("contents4") } + zip.putNextEntry("/${source.relativize(precedingSlashFile)}", precedingSlashFile) + } + } + + extractZipFile(sourceZip, target) + + assertThat(target).satisfiesKt { + val files = Files.list(it).use { stream -> stream.toList() } + assertThat(files.size).isEqualTo(4) + assertThat(target.resolve("file1")).hasContent("contents1") + assertThat(target.resolve("file2")).hasContent("contents2") + assertThat(target.resolve("file3")).hasContent("contents3") + assertThat(target.resolve("file4")).hasContent("contents4") + } + } + + @Test + fun `extractZipFile respects posix`(@TempDir tempDir: Path) { + assumeTrue(tempDir.hasPosixFilePermissions()) + + val source = tempDir.resolve("source").also { it.createDirectory() } + val target = tempDir.resolve("target").also { it.createDirectory() } + + source.resolve("regularFile").also { + it.writeText("contents1") + it.setPosixFilePermissions(PosixFilePermissions.fromString("rw-r--r--")) + } + source.resolve("executableFile").also { + it.writeText("contents2") + it.setPosixFilePermissions(PosixFilePermissions.fromString("rwxr-xr-x")) + } + + val sourceZip = tempDir.resolve("source.zip") + FileSystems.newFileSystem( + sourceZip, + mapOf( + "create" to true, + ZIP_PROPERTY_POSIX to true, + ) + ).use { zipfs -> + Files.walk(source).use { paths -> + paths + .filter { it.isRegularFile() } + .forEach { file -> + Files.copy(file, zipfs.getPath("/").resolve(source.relativize(file).toString()), StandardCopyOption.COPY_ATTRIBUTES) + } + } + } + + extractZipFile(sourceZip, target) + + assertThat(target).satisfiesKt { + val files = Files.list(it).use { stream -> stream.toList() } + assertThat(files.size).isEqualTo(2) + assertPosixPermissions(target.resolve("regularFile"), "rw-r--r--") + assertPosixPermissions(target.resolve("executableFile"), "rwxr-xr-x") + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt new file mode 100644 index 00000000000..b5a1bd32fac --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt @@ -0,0 +1,122 @@ +// 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.testFramework.ApplicationExtension +import com.intellij.testFramework.utils.io.createFile +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockkStatic +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.core.getTextFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path +import java.nio.file.Paths + +@ExtendWith(ApplicationExtension::class, MockitoExtension::class, MockKExtension::class) +class ManifestFetcherTest { + + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifest: ManifestManager.Manifest + private lateinit var manifestManager: ManifestManager + + @BeforeEach + fun setup() { + manifestFetcher = spy(ManifestFetcher()) + manifestManager = spy(ManifestManager()) + manifest = ManifestManager.Manifest() + } + + @Test + fun `should return null when both local and remote manifests are null`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null) + whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(null) + + assertThat(manifestFetcher.fetch()).isNull() + } + + @Test + fun `should return valid result from local should not execute remote method`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(manifest) + + assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest) + verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal() + verify(manifestFetcher, never()).fetchManifestFromRemote() + } + + @Test + fun `should return valid result from remote`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null) + whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(manifest) + + assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest) + verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal() + verify(manifestFetcher, atLeastOnce()).fetchManifestFromRemote() + } + + @Test + fun `fetchManifestFromRemote should return null due to invalid manifestString`() { + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + every { getTextFromUrl(any()) } returns "ManifestContent" + + assertThat(manifestFetcher.fetchManifestFromRemote()).isNull() + } + + @Test + fun `fetchManifestFromRemote should return manifest and update manifest`() { + val validManifest = ManifestManager.Manifest(manifestSchemaVersion = "1.0") + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + + every { getTextFromUrl(any()) } returns "{ \"manifestSchemaVersion\": \"1.0\" }" + + val result = manifestFetcher.fetchManifestFromRemote() + assertThat(result).isNotNull().isEqualTo(validManifest) + } + + @Test + fun `fetchManifestFromRemote should return null if manifest is deprecated`() { + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + every { getTextFromUrl(any()) } returns + // language=JSON + """ + { + "manifestSchemaVersion": "1.0", + "isManifestDeprecated": true + } + """.trimIndent() + + assertThat(manifestFetcher.fetchManifestFromRemote()).isNull() + } + + @Test + fun `fetchManifestFromLocal should return null if path does not exist locally`() { + whenever(manifestFetcher.lspManifestFilePath).thenReturn(Paths.get("does", "not", "exist")) + assertThat(manifestFetcher.fetchManifestFromLocal()).isNull() + } + + @Test + fun `fetchManifestFromLocal should return local path if exists locally`(@TempDir tempDir: Path) { + val manifestFile = tempDir.createFile("manifest.json") + manifestFile.toFile().writeText( + // language=JSON + """ + { + "manifestSchemaVersion": "1.0" + } + """.trimIndent() + ) + whenever(manifestFetcher.lspManifestFilePath).thenReturn(manifestFile) + assertThat(manifestFetcher.fetchManifestFromLocal()).isNull() + } +} 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/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt new file mode 100644 index 00000000000..d141268d2c3 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt @@ -0,0 +1,253 @@ +// 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.service +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ProjectExtension +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +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 org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +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.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.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 { + companion object { + @JvmField + @RegisterExtension + val projectExtension = ProjectExtension() + + private const val TEST_ACCESS_TOKEN = "test-access-token" + } + + private lateinit var project: 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 + + @BeforeEach + fun setUp() { + project = spyk(projectExtension.project) + setupMockLspService() + setupMockMessageBus() + setupMockConnectionManager() + } + + private fun setupMockLspService() { + mockLanguageServer = mockk() + mockEncryptionManager = mockk { + every { encrypt(any()) } returns "mock-encrypted-data" + } + + val mockLspService = mockk() + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + every { + mockLanguageServer.updateTokenCredentials(any()) + } returns CompletableFuture() + + every { + mockLanguageServer.deleteTokenCredentials() + } returns CompletableFuture.completedFuture(Unit) + + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + } + + private fun setupMockMessageBus() { + val messageBus = mockk() + val mockConnection = mockk { + every { subscribe(any(), any()) } just runs + } + every { project.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 { project.service() } returns mockConnectionManager + 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 { 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 + } + } + + @Test + fun `activeConnectionChanged updates token when connection ID matches Q connection`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + val newConnection = createMockConnection("new-token", "connection-id") + every { mockConnection.id } returns "connection-id" + + sut.activeConnectionChanged(newConnection) + + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `activeConnectionChanged does not update token when connection ID differs`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + val newConnection = createMockConnection("new-token", "different-id") + every { mockConnection.id } returns "q-connection-id" + + sut.activeConnectionChanged(newConnection) + + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `onChange updates token with new connection`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + setupMockConnectionManager("updated-token") + + sut.onChange("providerId", listOf("new-scope")) + + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `init does not update token when Q is not connected`() { + every { isQConnected(project) } returns false + every { isQExpired(project) } returns false + + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `init does not update token when Q is expired`() { + every { isQConnected(project) } returns true + every { isQExpired(project) } returns true + + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `test updateTokenCredentials unencrypted success`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + val token = "unencryptedToken" + val isEncrypted = false + + sut.updateTokenCredentials(token, isEncrypted) + + verify(exactly = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + token, + isEncrypted + ) + ) + } + } + + @Test + fun `test updateTokenCredentials encrypted success`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + val encryptedToken = "encryptedToken" + val decryptedToken = "decryptedToken" + val isEncrypted = true + + every { mockEncryptionManager.encrypt(any()) } returns encryptedToken + + sut.updateTokenCredentials(decryptedToken, isEncrypted) + + verify(atLeast = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + encryptedToken, + isEncrypted + ) + ) + } + } + + @Test + fun `test deleteTokenCredentials success`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + every { mockLanguageServer.deleteTokenCredentials() } returns CompletableFuture.completedFuture(Unit) + + sut.deleteTokenCredentials() + + verify(exactly = 1) { mockLanguageServer.deleteTokenCredentials() } + } + + @Test + fun `init results in token update`() { + every { isQConnected(any()) } returns true + every { isQExpired(any()) } returns false + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt new file mode 100644 index 00000000000..618b768a69a --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt @@ -0,0 +1,202 @@ +// 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.dependencies + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootEvent +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.verify +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +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.dependencies.ModuleDependencyProvider.Companion.EP_NAME +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture +import java.util.function.Consumer + +class DefaultModuleDependenciesServiceTest { + private lateinit var project: Project + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockModuleManager: ModuleManager + private lateinit var sut: DefaultModuleDependenciesService + private lateinit var mockApplication: Application + private lateinit var mockDependencyProvider: ModuleDependencyProvider + + @BeforeEach + fun setUp() { + project = mockk() + mockModuleManager = mockk() + mockDependencyProvider = mockk() + mockLanguageServer = mockk() + + every { mockLanguageServer.didChangeDependencyPaths(any()) } returns CompletableFuture() + + // Mock Application + mockApplication = mockk() + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns mockApplication + + // Mock message bus + val messageBus = mockk() + every { project.messageBus } returns messageBus + val mockConnection = mockk() + every { messageBus.connect(any()) } returns mockConnection + every { mockConnection.subscribe(any(), any()) } just runs + + // Mock ModuleManager + mockkStatic(ModuleManager::class) + every { ModuleManager.getInstance(project) } returns mockModuleManager + every { mockModuleManager.modules } returns Array(0) { mockk() } + + // Mock LSP service + val mockLspService = mockk() + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + // Mock extension point + mockkObject(ModuleDependencyProvider.Companion) + val epName = mockk>() + every { EP_NAME } returns epName + every { epName.forEachExtensionSafe(any()) } answers { + val callback = firstArg<(ModuleDependencyProvider) -> Unit>() + callback(mockDependencyProvider) + } + } + + @Test + fun `test initial sync on construction`() { + // Arrange + val module = mockk() + val params = DidChangeDependencyPathsParams( + moduleName = "testModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + + every { mockModuleManager.modules } returns arrayOf(module) + prepDependencyProvider(listOf(Pair(module, params))) + + sut = DefaultModuleDependenciesService(project, mockk()) + + verify { mockLanguageServer.didChangeDependencyPaths(params) } + } + + @Test + fun `test rootsChanged with multiple modules`() { + // Arrange + val module1 = mockk() + val module2 = mockk() + val params1 = DidChangeDependencyPathsParams( + moduleName = "module1", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency1.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + val params2 = DidChangeDependencyPathsParams( + moduleName = "module2", + runtimeLanguage = "python", + paths = listOf("/path/to/site-packages/package1"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + + prepDependencyProvider( + listOf( + Pair(module1, params1), + Pair(module2, params2) + ) + ) + + sut = DefaultModuleDependenciesService(project, mockk()) + + verify { mockLanguageServer.didChangeDependencyPaths(params1) } + verify { mockLanguageServer.didChangeDependencyPaths(params2) } + } + + @Test + fun `test rootsChanged withFileTypesChange`() { + // Arrange + val module = mockk() + val params = DidChangeDependencyPathsParams( + moduleName = "testModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + prepDependencyProvider(listOf(Pair(module, params))) + val event = mockk() + every { event.isCausedByFileTypesChange } returns true + + sut = DefaultModuleDependenciesService(project, mockk()) + + sut.rootsChanged(event) + + verify(exactly = 1) { mockLanguageServer.didChangeDependencyPaths(params) } + } + + @Test + fun `test rootsChanged after module changes`() { + // Arrange + val module = mockk() + val params = DidChangeDependencyPathsParams( + moduleName = "testModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + val event = mockk() + + every { mockModuleManager.modules } returns arrayOf(module) + every { event.isCausedByFileTypesChange } returns false + + prepDependencyProvider(listOf(Pair(module, params))) + + sut = DefaultModuleDependenciesService(project, mockk()) + + sut.rootsChanged(event) + + verify(exactly = 2) { mockLanguageServer.didChangeDependencyPaths(params) } + } + + private fun prepDependencyProvider(moduleParamPairs: List>) { + every { mockModuleManager.modules } returns moduleParamPairs.map { it.first }.toTypedArray() + + every { + EP_NAME.forEachExtensionSafe(any>()) + } answers { + val consumer = firstArg>() + moduleParamPairs.forEach { (module, params) -> + every { mockDependencyProvider.isApplicable(module) } returns true + every { mockDependencyProvider.createParams(module) } returns params + } + consumer.accept(mockDependencyProvider) + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt new file mode 100644 index 00000000000..365fd2759b0 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt @@ -0,0 +1,77 @@ +// 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.encryption + +import com.nimbusds.jose.JOSEException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicBoolean +import javax.crypto.spec.SecretKeySpec +import kotlin.random.Random + +class JwtEncryptionManagerTest { + @Test + fun `uses a different encryption key for each instance`() { + val blob = Random.Default.nextBytes(256) + val sut1 = JwtEncryptionManager() + val encrypted = sut1.encrypt(blob) + + assertThrows { + assertThat(sut1.decrypt(encrypted)) + .isNotEqualTo(JwtEncryptionManager().decrypt(encrypted)) + } + } + + @Test + @OptIn(ExperimentalStdlibApi::class) + fun `encryption is stable with static key`() { + val blob = Random.Default.nextBytes(256) + val bytes = "DEADBEEF".repeat(8).hexToByteArray() // 32 bytes + val key = SecretKeySpec(bytes, "HmacSHA256") + val sut1 = JwtEncryptionManager(key) + val encrypted = sut1.encrypt(blob) + + // each encrypt() call will use a different IV so we can't just directly compare + assertThat(sut1.decrypt(encrypted)) + .isEqualTo(JwtEncryptionManager(key).decrypt(encrypted)) + } + + @Test + fun `encryption can be round-tripped`() { + val sut = JwtEncryptionManager() + val blob = "DEADBEEF".repeat(8) + assertThat(sut.decrypt(sut.encrypt(blob))).isEqualTo(blob) + } + + @Test + @OptIn(ExperimentalStdlibApi::class) + fun writeInitializationPayload() { + val bytes = "DEADBEEF".repeat(8).hexToByteArray() // 32 bytes + val key = SecretKeySpec(bytes, "HmacSHA256") + + val closed = AtomicBoolean(false) + val os = object : ByteArrayOutputStream() { + override fun close() { + closed.set(true) + } + } + JwtEncryptionManager(key).writeInitializationPayload(os) + assertThat(os.toString()) + // Flare requires encryption ends with new line + // https://github.com/aws/language-server-runtimes/blob/4d7f81295dc12b59ed2e1c0ebaedb85ccb86cf76/runtimes/README.md#encryption + .endsWith("\n") + .isEqualTo( + // language=JSON + """ + |{"version":"1.0","mode":"JWT","key":"3q2-796tvu_erb7v3q2-796tvu_erb7v3q2-796tvu8"} + | + """.trimMargin() + ) + + // writeInitializationPayload should not close the stream + assertThat(closed.get()).isFalse + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt new file mode 100644 index 00000000000..96da1fc4318 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt @@ -0,0 +1,341 @@ +// 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.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.DidChangeTextDocumentParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DidSaveTextDocumentParams +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.eclipse.lsp4j.services.TextDocumentService +import org.junit.Before +import org.junit.Test +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.util.FileUriUtil +import java.net.URI +import java.nio.file.Path +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture + +class TextDocumentServiceHandlerTest { + private lateinit var project: Project + private lateinit var mockFileEditorManager: FileEditorManager + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockTextDocumentService: TextDocumentService + private lateinit var sut: TextDocumentServiceHandler + private lateinit var mockApplication: Application + + @Before + fun setup() { + project = mockk() + mockTextDocumentService = mockk() + mockLanguageServer = mockk() + + mockApplication = mockk() + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns mockApplication + every { mockApplication.executeOnPooledThread(any>()) } answers { + CompletableFuture.completedFuture(firstArg>().call()) + } + + // Mock the LSP service + val mockLspService = mockk() + + // Mock the service methods on Project + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + + // Mock the LSP service's executeSync method as a suspend function + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + // Mock workspace service + every { mockLanguageServer.textDocumentService } returns mockTextDocumentService + every { mockTextDocumentService.didChange(any()) } returns Unit + every { mockTextDocumentService.didSave(any()) } returns Unit + every { mockTextDocumentService.didOpen(any()) } returns Unit + every { mockTextDocumentService.didClose(any()) } returns Unit + + // Mock message bus + val messageBus = mockk() + every { project.messageBus } returns messageBus + val mockConnection = mockk() + every { messageBus.connect(any()) } returns mockConnection + every { mockConnection.subscribe(any(), any()) } just runs + + // Mock FileEditorManager + mockFileEditorManager = mockk() + every { mockFileEditorManager.openFiles } returns emptyArray() + every { project.getService(FileEditorManager::class.java) } returns mockFileEditorManager + + sut = TextDocumentServiceHandler(project, mockk()) + } + + @Test + fun `didSave runs on beforeDocumentSaving`() = runTest { + // Create test document and file + val uri = URI.create("file:///test/path/file.txt") + val document = mockk { + every { text } returns "test content" + } + + val file = createMockVirtualFile(uri) + + // Mock FileDocumentManager + val fileDocumentManager = mockk { + every { getFile(document) } returns file + } + + // Replace the FileDocumentManager instance + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + // Call the handler method + sut.beforeDocumentSaving(document) + + // Verify the correct LSP method was called with matching parameters + val paramsSlot = slot() + verify { mockTextDocumentService.didSave(capture(paramsSlot)) } + + with(paramsSlot.captured) { + assertThat(textDocument.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(text).isEqualTo("test content") + } + } + } + + @Test + fun `didOpen runs on service init`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val content = "test content" + val file = createMockVirtualFile(uri, content) + + every { mockFileEditorManager.openFiles } returns arrayOf(file) + + sut = TextDocumentServiceHandler(project, mockk()) + + val paramsSlot = slot() + verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } + + with(paramsSlot.captured.textDocument) { + assertThat(this.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(text).isEqualTo(content) + assertThat(languageId).isEqualTo("java") + assertThat(version).isEqualTo(1) + } + } + + @Test + fun `didOpen runs on fileOpened`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val content = "test content" + val file = createMockVirtualFile(uri, content) + + sut.fileOpened(mockk(), file) + + val paramsSlot = slot() + verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } + + with(paramsSlot.captured.textDocument) { + assertThat(this.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(text).isEqualTo(content) + assertThat(languageId).isEqualTo("java") + assertThat(version).isEqualTo(1) + } + } + + @Test + fun `didClose runs on fileClosed`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val file = createMockVirtualFile(uri) + + sut.fileClosed(mockk(), file) + + val paramsSlot = slot() + verify { mockTextDocumentService.didClose(capture(paramsSlot)) } + + assertThat(paramsSlot.captured.textDocument.uri).isEqualTo(normalizeFileUri(uri.toString())) + } + + @Test + fun `didChange runs on content change events`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val document = mockk { + every { text } returns "changed content" + every { modificationStamp } returns 123L + } + + val file = createMockVirtualFile(uri) + + val changeEvent = mockk { + every { this@mockk.file } returns file + } + + // Mock FileDocumentManager + val fileDocumentManager = mockk { + every { getCachedDocument(file) } returns document + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + // Call the handler method + sut.after(mutableListOf(changeEvent)) + } + + // Verify the correct LSP method was called with matching parameters + val paramsSlot = slot() + verify { mockTextDocumentService.didChange(capture(paramsSlot)) } + + with(paramsSlot.captured) { + assertThat(textDocument.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(textDocument.version).isEqualTo(123) + assertThat(contentChanges[0].text).isEqualTo("changed content") + } + } + + @Test + fun `didSave does not run when URI is empty`() = runTest { + val document = mockk() + val file = createMockVirtualFile(URI.create("")) + + mockkObject(FileUriUtil) { + every { FileUriUtil.toUriString(file) } returns null + + val fileDocumentManager = mockk { + every { getFile(document) } returns file + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + sut.beforeDocumentSaving(document) + + verify(exactly = 0) { mockTextDocumentService.didSave(any()) } + } + } + } + + @Test + fun `didSave does not run when file is null`() = runTest { + val document = mockk() + + val fileDocumentManager = mockk { + every { getFile(document) } returns null + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + sut.beforeDocumentSaving(document) + + verify(exactly = 0) { mockTextDocumentService.didSave(any()) } + } + } + + @Test + fun `didChange ignores non-content change events`() = runTest { + val nonContentEvent = mockk() // Some other type of VFileEvent + + sut.after(mutableListOf(nonContentEvent)) + + verify(exactly = 0) { mockTextDocumentService.didChange(any()) } + } + + @Test + fun `didChange skips files without cached documents`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val path = mockk { + every { toUri() } returns uri + } + val file = mockk { + every { toNioPath() } returns path + } + val changeEvent = mockk { + every { this@mockk.file } returns file + } + + val fileDocumentManager = mockk { + every { getCachedDocument(file) } returns null + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + sut.after(mutableListOf(changeEvent)) + + verify(exactly = 0) { mockTextDocumentService.didChange(any()) } + } + } + + private fun createMockVirtualFile( + uri: URI, + content: String = "", + fileTypeName: String = "JAVA", + modificationStamp: Long = 1L, + ): VirtualFile { + val path = mockk { + every { toUri() } returns uri + } + val inputStream = content.byteInputStream() + + val mockFileType = mockk { + every { name } returns fileTypeName + } + + return mockk { + every { url } returns uri.path + every { toNioPath() } returns path + every { isDirectory } returns false + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + every { this@mockk.inputStream } returns inputStream + every { fileType } returns mockFileType + every { this@mockk.modificationStamp } returns modificationStamp + } + } + + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + + if (!uri.startsWith("file:///")) { + return uri + } + + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt new file mode 100644 index 00000000000..4418cb33ac8 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt @@ -0,0 +1,153 @@ +// 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.util + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApplicationExtension::class) +class FileUriUtilTest { + + private fun createMockVirtualFile(path: String, mockProtocol: String = "file", mockIsDirectory: Boolean = false): VirtualFile = + mockk { + every { fileSystem } returns mockk { + every { protocol } returns mockProtocol + } + every { url } returns path + every { isDirectory } returns mockIsDirectory + } + + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + + if (!uri.startsWith("file:///")) { + return uri + } + + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } + + @Test + fun `test basic unix path`() { + val virtualFile = createMockVirtualFile("/path/to/file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/to/file.txt") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test unix directory path`() { + val virtualFile = createMockVirtualFile("/path/to/directory/", mockIsDirectory = true) + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/to/directory") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test path with spaces`() { + val virtualFile = createMockVirtualFile("/path/with spaces/file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/with%20spaces/file.txt") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test root path`() { + val virtualFile = createMockVirtualFile("/") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test path with multiple separators`() { + val virtualFile = createMockVirtualFile("/path//to///file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/to/file.txt") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test very long path`() { + val longPath = "/a".repeat(256) + "/file.txt" + val virtualFile = createMockVirtualFile(longPath) + val uri = FileUriUtil.toUriString(virtualFile) + if (uri != null) { + assertThat(uri.startsWith("file:///")).isTrue + assertThat(uri.endsWith("/file.txt")).isTrue + } + } + + @Test + fun `test relative path`() { + val virtualFile = createMockVirtualFile("./relative/path/file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + if (uri != null) { + assertThat(uri.contains("file.txt")).isTrue + assertThat(uri.startsWith("file:///")).isTrue + } + } + + @Test + fun `test jar protocol conversion`() { + val virtualFile = createMockVirtualFile( + "jar:file:///path/to/archive.jar!/com/example/Test.class", + "jar" + ) + val result = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example/Test.class") + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test jrt protocol conversion`() { + val virtualFile = createMockVirtualFile( + "jrt://java.base/java/lang/String.class", + "jrt" + ) + val result = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("jrt://java.base/java/lang/String.class") + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test invalid jar url returns null`() { + val virtualFile = createMockVirtualFile( + "invalid:url:format", + "jar" + ) + val result = FileUriUtil.toUriString(virtualFile) + assertThat(result).isNull() + } + + @Test + fun `test jar protocol with directory`() { + val virtualFile = createMockVirtualFile( + "jar:file:///path/to/archive.jar!/com/example/", + "jar", + true + ) + val result = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example") + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test empty url in jar protocol`() { + val virtualFile = createMockVirtualFile( + "", + "jar", + true + ) + val result = FileUriUtil.toUriString(virtualFile) + assertThat(result).isNull() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt new file mode 100644 index 00000000000..3ab9fd37c70 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt @@ -0,0 +1,110 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.vfs.VirtualFile +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URI + +class WorkspaceFolderUtilTest { + + @Test + fun `createWorkspaceFolders returns empty list when project is default`() { + val mockProject = mockk() + every { mockProject.isDefault } returns true + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertThat(result).isEmpty() + } + + @Test + fun `createWorkspaceFolders returns workspace folders for non-default project with modules`() { + val mockProject = mockk() + val mockModuleManager = mockk() + val mockModule1 = mockk() + val mockModule2 = mockk() + val mockModuleRootManager1 = mockk() + val mockModuleRootManager2 = mockk() + + val mockContentRoot1 = createMockVirtualFile( + URI("file:///path/to/root1"), + name = "root1" + ) + val mockContentRoot2 = createMockVirtualFile( + URI("file:///path/to/root2"), + name = "root2" + ) + + mockkStatic(ModuleManager::class, ModuleRootManager::class) + + every { mockProject.isDefault } returns false + every { ModuleManager.getInstance(mockProject) } returns mockModuleManager + every { mockModuleManager.modules } returns arrayOf(mockModule1, mockModule2) + every { mockModule1.name } returns "module1" + every { mockModule2.name } returns "module2" + every { ModuleRootManager.getInstance(mockModule1) } returns mockModuleRootManager1 + every { ModuleRootManager.getInstance(mockModule2) } returns mockModuleRootManager2 + every { mockModuleRootManager1.contentRoots } returns arrayOf(mockContentRoot1) + every { mockModuleRootManager2.contentRoots } returns arrayOf(mockContentRoot2) + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertThat(result).hasSize(2) + assertThat(result[0].uri).isEqualTo(normalizeFileUri("file:///path/to/root1")) + assertThat(result[1].uri).isEqualTo(normalizeFileUri("file:///path/to/root2")) + assertThat(result[0].name).isEqualTo("module1") + assertThat(result[1].name).isEqualTo("module2") + } + + @Test + fun `createWorkspaceFolders handles modules with no content roots`() { + val mockProject = mockk() + val mockModuleManager = mockk() + val mockModule = mockk() + val mockModuleRootManager = mockk() + + mockkStatic(ModuleManager::class, ModuleRootManager::class) + + every { mockProject.isDefault } returns false + every { ModuleManager.getInstance(mockProject) } returns mockModuleManager + every { mockModuleManager.modules } returns arrayOf(mockModule) + every { ModuleRootManager.getInstance(mockModule) } returns mockModuleRootManager + every { mockModuleRootManager.contentRoots } returns emptyArray() + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertThat(result).isEmpty() + } + + private fun createMockVirtualFile(uri: URI, name: String): VirtualFile = + mockk { + every { url } returns uri.toString() + every { getName() } returns name + every { isDirectory } returns false + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + } + + // for windows unit tests + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + if (!uri.startsWith("file:///")) { + return uri + } + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt new file mode 100644 index 00000000000..73a959d11b3 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt @@ -0,0 +1,760 @@ +// 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.workspace + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileCopyEvent +import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent +import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.CreateFilesParams +import org.eclipse.lsp4j.DeleteFilesParams +import org.eclipse.lsp4j.DidChangeWatchedFilesParams +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.FileChangeType +import org.eclipse.lsp4j.RenameFilesParams +import org.eclipse.lsp4j.WorkspaceFolder +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.eclipse.lsp4j.services.TextDocumentService +import org.eclipse.lsp4j.services.WorkspaceService +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +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.util.WorkspaceFolderUtil +import java.net.URI +import java.nio.file.Path +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture + +class WorkspaceServiceHandlerTest { + private lateinit var project: Project + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockWorkspaceService: WorkspaceService + private lateinit var mockTextDocumentService: TextDocumentService + private lateinit var sut: WorkspaceServiceHandler + private lateinit var mockApplication: Application + + @BeforeEach + fun setup() { + project = mockk() + mockWorkspaceService = mockk() + mockTextDocumentService = mockk() + mockLanguageServer = mockk() + + mockApplication = mockk() + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns mockApplication + every { mockApplication.executeOnPooledThread(any>()) } answers { + CompletableFuture.completedFuture(firstArg>().call()) + } + + // Mock the LSP service + val mockLspService = mockk() + + // Mock the service methods on Project + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + + // Mock the LSP service's executeSync method as a suspend function + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + // Mock workspace service + every { mockLanguageServer.workspaceService } returns mockWorkspaceService + every { mockWorkspaceService.didCreateFiles(any()) } returns Unit + every { mockWorkspaceService.didDeleteFiles(any()) } returns Unit + every { mockWorkspaceService.didRenameFiles(any()) } returns Unit + every { mockWorkspaceService.didChangeWatchedFiles(any()) } returns Unit + every { mockWorkspaceService.didChangeWorkspaceFolders(any()) } returns Unit + + // Mock textDocument service (for didRename calls) + every { mockLanguageServer.textDocumentService } returns mockTextDocumentService + every { mockTextDocumentService.didOpen(any()) } returns Unit + every { mockTextDocumentService.didClose(any()) } returns Unit + + // Mock message bus + val messageBus = mockk() + every { project.messageBus } returns messageBus + val mockConnection = mockk() + every { messageBus.connect(any()) } returns mockConnection + every { mockConnection.subscribe(any(), any()) } just runs + + sut = WorkspaceServiceHandler(project, mockk()) + } + + @Test + fun `test didCreateFiles with Python file`() = runTest { + val pyUri = URI("file:///test/path") + val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Created, false, "py") + + sut.after(listOf(pyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(pyUri.toString())) + } + + @Test + fun `test didCreateFiles with TypeScript file`() = runTest { + val tsUri = URI("file:///test/path") + val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Created, false, "ts") + + sut.after(listOf(tsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(tsUri.toString())) + } + + @Test + fun `test didCreateFiles with JavaScript file`() = runTest { + val jsUri = URI("file:///test/path") + val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Created, false, "js") + + sut.after(listOf(jsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(jsUri.toString())) + } + + @Test + fun `test didCreateFiles with Java file`() = runTest { + val javaUri = URI("file:///test/path") + val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Created, false, "java") + + sut.after(listOf(javaEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(javaUri.toString())) + } + + @Test + fun `test didCreateFiles called for directory`() = runTest { + val dirUri = URI("file:///test/directory/path") + val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Created, true, "") + + sut.after(listOf(dirEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(dirUri.toString())) + } + + @Test + fun `test didCreateFiles not called for unsupported file extension`() = runTest { + val txtUri = URI("file:///test/path") + val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Created, false, "txt") + + sut.after(listOf(txtEvent)) + + verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) } + } + + @Test + fun `test didCreateFiles with move event`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") + + sut.after(listOf(moveEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) + } + + @Test + fun `test didCreateFiles with copy event`() = runTest { + val originalUri = URI("file:///test/original") + val newUri = URI("file:///test/new") + val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") + + sut.after(listOf(copyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) + } + + @Test + fun `test didDeleteFiles with Python file`() = runTest { + val pyUri = URI("file:///test/path") + val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Deleted, false, "py") + + sut.after(listOf(pyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(pyUri.toString())) + } + + @Test + fun `test didDeleteFiles with TypeScript file`() = runTest { + val tsUri = URI("file:///test/path") + val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Deleted, false, "ts") + + sut.after(listOf(tsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(tsUri.toString())) + } + + @Test + fun `test didDeleteFiles with JavaScript file`() = runTest { + val jsUri = URI("file:///test/path") + val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Deleted, false, "js") + + sut.after(listOf(jsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(jsUri.toString())) + } + + @Test + fun `test didDeleteFiles with Java file`() = runTest { + val javaUri = URI("file:///test/path") + val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Deleted, false, "java") + + sut.after(listOf(javaEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(javaUri.toString())) + } + + @Test + fun `test didDeleteFiles not called for unsupported file extension`() = runTest { + val txtUri = URI("file:///test/path") + val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Deleted, false, "txt") + + sut.after(listOf(txtEvent)) + + verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } + } + + @Test + fun `test didDeleteFiles called for directory`() = runTest { + val dirUri = URI("file:///test/directory/path") + val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Deleted, true, "") + + sut.after(listOf(dirEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(dirUri.toString())) + } + + @Test + fun `test didDeleteFiles handles both delete and move events in same batch`() = runTest { + val deleteUri = URI("file:///test/deleteFile") + val oldMoveUri = URI("file:///test/oldMoveFile") + val newMoveUri = URI("file:///test/newMoveFile") + + val deleteEvent = createMockVFileEvent(deleteUri, FileChangeType.Deleted, false, "py") + val moveEvent = createMockVFileMoveEvent(oldMoveUri, newMoveUri, "test.py") + + sut.after(listOf(deleteEvent, moveEvent)) + + val deleteParamsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } + assertThat(deleteParamsSlot.captured.files).hasSize(2) + assertThat(deleteParamsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(deleteUri.toString())) + assertThat(deleteParamsSlot.captured.files[1].uri).isEqualTo(normalizeFileUri(oldMoveUri.toString())) + } + + @Test + fun `test didDeleteFiles with move event of unsupported file type`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.txt") + + sut.after(listOf(moveEvent)) + + verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } + } + + @Test + fun `test didDeleteFiles with move event of directory`() = runTest { + val oldUri = URI("file:///test/oldDir") + val newUri = URI("file:///test/newDir") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "", true) + + sut.after(listOf(moveEvent)) + + val deleteParamsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } + assertThat(deleteParamsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(oldUri.toString())) + } + + @Test + fun `test didChangeWatchedFiles with valid events`() = runTest { + // Arrange + val createURI = URI("file:///test/pathOfCreation") + val deleteURI = URI("file:///test/pathOfDeletion") + val changeURI = URI("file:///test/pathOfChange") + + val virtualFileCreate = createMockVFileEvent(createURI, FileChangeType.Created, false) + val virtualFileDelete = createMockVFileEvent(deleteURI, FileChangeType.Deleted, false) + val virtualFileChange = createMockVFileEvent(changeURI, FileChangeType.Changed, false) + + // Act + sut.after(listOf(virtualFileCreate, virtualFileDelete, virtualFileChange)) + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(createURI.toString())) + assertThat(paramsSlot.captured.changes[0].type).isEqualTo(FileChangeType.Created) + assertThat(paramsSlot.captured.changes[1].uri).isEqualTo(normalizeFileUri(deleteURI.toString())) + assertThat(paramsSlot.captured.changes[1].type).isEqualTo(FileChangeType.Deleted) + assertThat(paramsSlot.captured.changes[2].uri).isEqualTo(normalizeFileUri(changeURI.toString())) + assertThat(paramsSlot.captured.changes[2].type).isEqualTo(FileChangeType.Changed) + } + + @Test + fun `test didChangeWatchedFiles with move event reports both delete and create`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") + + sut.after(listOf(moveEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + + assertThat(paramsSlot.captured.changes).hasSize(2) + assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(oldUri.toString())) + assertThat(paramsSlot.captured.changes[0].type).isEqualTo(FileChangeType.Deleted) + assertThat(paramsSlot.captured.changes[1].uri).isEqualTo(normalizeFileUri(newUri.toString())) + assertThat(paramsSlot.captured.changes[1].type).isEqualTo(FileChangeType.Created) + } + + @Test + fun `test didChangeWatchedFiles with copy event`() = runTest { + val originalUri = URI("file:///test/original") + val newUri = URI("file:///test/new") + val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") + + sut.after(listOf(copyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) + assertThat(paramsSlot.captured.changes[0].type).isEqualTo(FileChangeType.Created) + } + + @Test + fun `test no invoked messages when events are empty`() = runTest { + // Act + sut.after(emptyList()) + + // Assert + verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) } + verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } + verify(exactly = 0) { mockWorkspaceService.didChangeWatchedFiles(any()) } + } + + @Test + fun `test didRenameFiles with supported file`() = runTest { + // Arrange + val oldName = "oldFile.java" + val newName = "newFile.java" + val propertyEvent = createMockPropertyChangeEvent( + oldName = oldName, + newName = newName, + isDirectory = false, + fileTypeName = "JAVA", + modificationStamp = 123L + ) + + // Act + sut.after(listOf(propertyEvent)) + + val closeParams = slot() + verify { mockTextDocumentService.didClose(capture(closeParams)) } + assertThat(closeParams.captured.textDocument.uri).isEqualTo(normalizeFileUri("file:///testDir/$oldName")) + + val openParams = slot() + verify { mockTextDocumentService.didOpen(capture(openParams)) } + with(openParams.captured.textDocument) { + assertThat(uri).isEqualTo(normalizeFileUri("file:///testDir/$newName")) + assertThat(text).isEqualTo("content") + assertThat(languageId).isEqualTo("java") + assertThat(version).isEqualTo(123) + } + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + with(paramsSlot.captured.files[0]) { + assertThat(oldUri).isEqualTo(normalizeFileUri("file:///testDir/$oldName")) + assertThat(newUri).isEqualTo(normalizeFileUri("file:///testDir/$newName")) + } + } + + @Test + fun `test didRenameFiles with unsupported file type`() = runTest { + // Arrange + val propertyEvent = createMockPropertyChangeEvent( + oldName = "oldFile.txt", + newName = "newFile.txt", + isDirectory = false, + ) + + // Act + sut.after(listOf(propertyEvent)) + + // Assert + verify(exactly = 0) { mockTextDocumentService.didClose(any()) } + verify(exactly = 0) { mockTextDocumentService.didOpen(any()) } + verify(exactly = 0) { mockWorkspaceService.didRenameFiles(any()) } + } + + @Test + fun `test didRenameFiles with directory`() = runTest { + // Arrange + val propertyEvent = createMockPropertyChangeEvent( + oldName = "oldDir", + newName = "newDir", + isDirectory = true + ) + + // Act + sut.after(listOf(propertyEvent)) + + // Assert + verify(exactly = 0) { mockTextDocumentService.didClose(any()) } + verify(exactly = 0) { mockTextDocumentService.didOpen(any()) } + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + with(paramsSlot.captured.files[0]) { + assertThat(oldUri).isEqualTo(normalizeFileUri("file:///testDir/oldDir")) + assertThat(newUri).isEqualTo(normalizeFileUri("file:///testDir/newDir")) + } + } + + @Test + fun `test didRenameFiles with multiple files`() = runTest { + // Arrange + val event1 = createMockPropertyChangeEvent( + oldName = "old1.java", + newName = "new1.java", + fileTypeName = "JAVA", + modificationStamp = 123L + ) + val event2 = createMockPropertyChangeEvent( + oldName = "old2.py", + newName = "new2.py", + fileTypeName = "Python", + modificationStamp = 456L + ) + + // Act + sut.after(listOf(event1, event2)) + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files).hasSize(2) + + // Verify didClose and didOpen for both files + verify(exactly = 2) { mockTextDocumentService.didClose(any()) } + + val openParamsSlot = mutableListOf() + verify(exactly = 2) { mockTextDocumentService.didOpen(capture(openParamsSlot)) } + + assertThat(openParamsSlot[0].textDocument.languageId).isEqualTo("java") + assertThat(openParamsSlot[0].textDocument.version).isEqualTo(123) + assertThat(openParamsSlot[1].textDocument.languageId).isEqualTo("python") + assertThat(openParamsSlot[1].textDocument.version).isEqualTo(456) + } + + @Test + fun `rootsChanged does not notify when no changes`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val folders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + every { WorkspaceFolderUtil.createWorkspaceFolders(any()) } returns folders + + // Act + sut.beforeRootsChange(mockk()) + sut.rootsChanged(mockk()) + + // Assert + verify(exactly = 0) { mockWorkspaceService.didChangeWorkspaceFolders(any()) } + } + + // rootsChanged handles + @Test + fun `rootsChanged handles init`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = emptyList() + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.added).hasSize(1) + assertThat(paramsSlot.captured.event.added[0].name).isEqualTo("folder1") + } + + // rootsChanged handles additional files added to root + @Test + fun `rootsChanged handles additional files added to root`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder2" + uri = "file:///path/to/folder2" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.added).hasSize(1) + assertThat(paramsSlot.captured.event.added[0].name).isEqualTo("folder2") + } + + // rootsChanged handles removal of files from root + @Test + fun `rootsChanged handles removal of files from root`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder2" + uri = "file:///path/to/folder2" + } + ) + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.removed).hasSize(1) + assertThat(paramsSlot.captured.event.removed[0].name).isEqualTo("folder2") + } + + @Test + fun `rootsChanged handles multiple simultaneous additions and removals`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder2" + uri = "file:///path/to/folder2" + } + ) + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder3" + uri = "file:///path/to/folder3" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.added).hasSize(1) + assertThat(paramsSlot.captured.event.removed).hasSize(1) + assertThat(paramsSlot.captured.event.added[0].name).isEqualTo("folder3") + assertThat(paramsSlot.captured.event.removed[0].name).isEqualTo("folder2") + } + + private fun createMockVirtualFile( + uri: URI, + fileName: String, + isDirectory: Boolean = false, + fileTypeName: String = "PLAIN_TEXT", + modificationStamp: Long = 1L, + ): VirtualFile { + val nioPath = mockk { + every { toUri() } returns uri + } + val mockFileType = mockk { + every { name } returns fileTypeName + } + return mockk { + every { this@mockk.isDirectory } returns isDirectory + every { toNioPath() } returns nioPath + every { url } returns uri.path + every { path } returns "${uri.path}/$fileName" + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + every { this@mockk.inputStream } returns "content".byteInputStream() + every { fileType } returns mockFileType + every { this@mockk.modificationStamp } returns modificationStamp + } + } + + private fun createMockVFileEvent( + uri: URI, + type: FileChangeType = FileChangeType.Changed, + isDirectory: Boolean = false, + extension: String = "py", + ): VFileEvent { + val virtualFile = createMockVirtualFile(uri, "test.$extension", isDirectory) + return when (type) { + FileChangeType.Deleted -> mockk() + FileChangeType.Created -> mockk() + else -> mockk() + }.apply { + every { file } returns virtualFile + } + } + + private fun createMockPropertyChangeEvent( + oldName: String, + newName: String, + isDirectory: Boolean = false, + fileTypeName: String = "PLAIN_TEXT", + modificationStamp: Long = 1L, + ): VFilePropertyChangeEvent { + val parent = createMockVirtualFile(URI("file:///testDir/"), "testDir", true) + val newUri = URI("file:///testDir/$newName") + val file = createMockVirtualFile(newUri, newName, isDirectory, fileTypeName, modificationStamp) + every { file.parent } returns parent + + return mockk().apply { + every { propertyName } returns VirtualFile.PROP_NAME + every { this@apply.file } returns file + every { oldValue } returns oldName + every { newValue } returns newName + } + } + + private fun createMockVFileMoveEvent(oldUri: URI, newUri: URI, fileName: String, isDirectory: Boolean = false): VFileMoveEvent { + val oldFile = createMockVirtualFile(oldUri, fileName, isDirectory) + val newFile = createMockVirtualFile(newUri, fileName, isDirectory) + return mockk().apply { + every { file } returns newFile + every { oldPath } returns oldUri.path + every { oldParent } returns oldFile + } + } + + private fun createMockVFileCopyEvent(originalUri: URI, newUri: URI, fileName: String): VFileCopyEvent { + val newParent = mockk { + every { findChild(any()) } returns createMockVirtualFile(newUri, fileName) + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + } + return mockk().apply { + every { file } returns createMockVirtualFile(originalUri, fileName) + every { this@apply.newParent } returns newParent + every { newChildName } returns fileName + } + } + + // for windows unit tests + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + + if (!uri.startsWith("file:///")) { + return uri + } + + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt new file mode 100644 index 00000000000..6b9d425ba3d --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt @@ -0,0 +1,95 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.util.xmlb.XmlSerializer +import org.assertj.core.api.Assertions.assertThat +import org.jdom.output.XMLOutputter +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import software.aws.toolkits.jetbrains.utils.xmlElement + +class LspSettingsTest { + private lateinit var lspSettings: LspSettings + + @BeforeEach + fun setUp() { + lspSettings = LspSettings() + lspSettings.loadState(LspConfiguration()) + } + + @Test + fun `artifact path is null by default`() { + assertThat(lspSettings.getArtifactPath()).isNull() + } + + @Test + fun `artifact path can be set`() { + lspSettings.setArtifactPath("test\\lsp.js") + assertThat(lspSettings.getArtifactPath()) + .isEqualTo("test\\lsp.js") + } + + @Test + fun `empty artifact path is null`() { + lspSettings.setArtifactPath("") + assertThat(lspSettings.getArtifactPath()).isNull() + } + + @Test + fun `blank artifact path is null`() { + lspSettings.setArtifactPath(" ") + assertThat(lspSettings.getArtifactPath()).isNull() + } + + @Test + fun `serialize settings to ensure backwards compatibility`() { + val element = xmlElement( + """ + + + """.trimIndent() + ) + lspSettings.setArtifactPath("temp\\lsp.js") + + XmlSerializer.serializeInto(lspSettings.state, element) + + val actual = XMLOutputter().outputString(element) + + // language=XML + val expected = """ + + + """.trimIndent() + + assertThat(actual).isEqualToIgnoringWhitespace(expected) + } + + @Test + fun `deserialize empty settings to ensure backwards compatibility`() { + val element = xmlElement( + """ + + + """ + ) + val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java) + assertThat(actual.artifactPath).isNull() + } + + @Test + fun `deserialize existing settings to ensure backwards compatibility`() { + val element = xmlElement( + """ + + + """.trimIndent() + ) + val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java) + assertThat(actual.artifactPath).isNotEmpty() + assertThat(actual.artifactPath).isEqualTo("temp\\lsp.js") + } +} diff --git a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml index 0c8b7b396e4..f1616e4db80 100644 --- a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml +++ b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml @@ -7,4 +7,10 @@ + + + + + diff --git a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml index 7998b8bbaa1..56887d944f0 100644 --- a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml +++ b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml @@ -1,9 +1,15 @@ + + + + + diff --git a/plugins/amazonq/src/main/resources/META-INF/plugin.xml b/plugins/amazonq/src/main/resources/META-INF/plugin.xml index 05385174cfe..3d8f95579e5 100644 --- a/plugins/amazonq/src/main/resources/META-INF/plugin.xml +++ b/plugins/amazonq/src/main/resources/META-INF/plugin.xml @@ -85,11 +85,21 @@ + defaultValue="" restartRequired="true"/> + + + + + diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt index 9e209a1a32d..dc4755688b0 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt @@ -212,3 +212,6 @@ private fun tryOrLogShortException(log: Logger, block: () -> T) = try { log.warn { "${e::class.simpleName}: ${e.message}" } null } + +// https://github.com/corretto/corretto-21/blob/364eb35886643e504344136075f4a2442d6c0cb0/src/jdk.zipfs/share/classes/jdk/nio/zipfs/ZipFileSystem.java#L90C33-L90C78 +const val ZIP_PROPERTY_POSIX = "enablePosixFileAttributes" diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt index a91ea7b9686..b1229f570ab 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt @@ -3,7 +3,6 @@ package software.aws.toolkits.core.utils -import java.io.BufferedInputStream import java.io.ByteArrayInputStream import java.io.IOException import java.io.InputStream @@ -17,7 +16,7 @@ import java.util.zip.ZipOutputStream */ fun ZipOutputStream.putNextEntry(entryName: String, file: Path) { try { - BufferedInputStream(Files.newInputStream(file)).use { inputStream -> + Files.newInputStream(file).buffered().use { inputStream -> putNextEntry(entryName, inputStream) } } catch (e: IOException) { diff --git a/plugins/core/jetbrains-community/resources/telemetryOverride.json b/plugins/core/jetbrains-community/resources/telemetryOverride.json index 5ad09ab9c0e..d0f27341496 100644 --- a/plugins/core/jetbrains-community/resources/telemetryOverride.json +++ b/plugins/core/jetbrains-community/resources/telemetryOverride.json @@ -1,5 +1,20 @@ { "types": [ + { + "name": "amazonQProfileRegion", + "type": "string", + "description": "Region of the Q Profile associated with a metric\n- \"n/a\" if metric is not associated with a profile or region.\n- \"not-set\" if metric is associated with a profile, but profile is unknown." + }, + { + "name": "ssoRegion", + "type": "string", + "description": "Region of the current SSO connection. Typically associated with credentialStartUrl\n- \"n/a\" if metric is not associated with a region.\n- \"not-set\" if metric is associated with a region, but region is unknown." + }, + { + "name": "profileCount", + "type": "int", + "description": "The number of profiles that were available to choose from" + }, { "name": "amazonqIndexFileSizeInMB", "type": "int", @@ -105,6 +120,31 @@ } ], "metrics": [ + { + "name": "amazonq_didSelectProfile", + "description": "Emitted after the user's Q Profile has been set, whether the user was prompted with a dialog, or a profile was automatically assigned after signing in.", + "metadata": [ + { "type": "source" }, + { "type": "amazonQProfileRegion" }, + { "type": "result" }, + { "type": "ssoRegion", "required": false }, + { "type": "credentialStartUrl", "required": false }, + { "type": "profileCount", "required": false } + ], + "passive": true + }, + { + "name": "amazonq_profileState", + "description": "Indicates a change in the user's Q Profile state", + "metadata": [ + { "type": "source" }, + { "type": "amazonQProfileRegion" }, + { "type": "result" }, + { "type": "ssoRegion", "required": false }, + { "type": "credentialStartUrl", "required": false } + ], + "passive": true + }, { "name": "amazonq_indexWorkspace", "description": "Indexing of local workspace", diff --git a/plugins/core/jetbrains-community/src-241-242/software/aws/toolkits/jetbrains/PluginVersionChecker.kt b/plugins/core/jetbrains-community/src-242/software/aws/toolkits/jetbrains/PluginVersionChecker.kt similarity index 100% rename from plugins/core/jetbrains-community/src-241-242/software/aws/toolkits/jetbrains/PluginVersionChecker.kt rename to plugins/core/jetbrains-community/src-242/software/aws/toolkits/jetbrains/PluginVersionChecker.kt diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt index af45361ac3d..1caf2e8e463 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt @@ -4,7 +4,6 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.application.PathManager -import com.intellij.util.io.HttpRequests import com.intellij.util.io.createDirectories import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver import software.aws.toolkits.core.utils.UrlFetcher @@ -41,11 +40,7 @@ class DefaultRemoteResourceResolverProvider : RemoteResourceResolverProvider { } override fun getETag(url: String): String = - HttpRequests.head(url) - .userAgent("AWS Toolkit for JetBrains") - .connect { request -> - request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() - } + getETagFromUrl(url) } } } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt index 27958005fe4..8456be0f2bc 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt @@ -24,3 +24,10 @@ fun writeJsonToUrl(url: String, jsonString: String, indicator: ProgressIndicator request.write(jsonString) request.readString(indicator) } + +fun getETagFromUrl(url: String): String = + HttpRequests.head(url) + .userAgent(AwsClientManager.getUserAgent()) + .connect { request -> + request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() + } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt index 1188db0a60c..bb87abad15a 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt @@ -125,6 +125,11 @@ enum class HelpIds(shortId: String, val url: String) { "ToolkitAddConnectionsDialog", "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/setup-credentials.html" ), + + Q_SWITCH_PROFILES_DIALOG( + "QSwitchProfilesDialog", + "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/subscribe-understanding-profile.html" + ), ; val id = "$HELP_ID_PREFIX.$shortId" diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/BrowserMessage.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/BrowserMessage.kt index 01cc00c48fa..5d7afa86bd3 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/BrowserMessage.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/BrowserMessage.kt @@ -25,7 +25,11 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo JsonSubTypes.Type(value = BrowserMessage.CancelLogin::class, name = "cancelLogin"), JsonSubTypes.Type(value = BrowserMessage.Signout::class, name = "signout"), JsonSubTypes.Type(value = BrowserMessage.Reauth::class, name = "reauth"), - JsonSubTypes.Type(value = BrowserMessage.SendUiClickTelemetry::class, name = "sendUiClickTelemetry") + JsonSubTypes.Type(value = BrowserMessage.SendUiClickTelemetry::class, name = "sendUiClickTelemetry"), + JsonSubTypes.Type(value = BrowserMessage.SwitchProfile::class, name = "switchProfile"), + JsonSubTypes.Type(value = BrowserMessage.PublishWebviewTelemetry::class, name = "webviewTelemetry"), + JsonSubTypes.Type(value = BrowserMessage.OpenUrl::class, name = "openUrl"), + JsonSubTypes.Type(value = BrowserMessage.ListProfiles::class, name = "listProfiles") ) sealed interface BrowserMessage { @@ -57,5 +61,18 @@ sealed interface BrowserMessage { object Reauth : BrowserMessage + data class OpenUrl(val externalLink: String) : BrowserMessage + + data class SwitchProfile( + val profileName: String, + val accountId: String, + val region: String, + val arn: String, + ) : BrowserMessage + + object ListProfiles : BrowserMessage + data class SendUiClickTelemetry(val signInOptionClicked: String?) : BrowserMessage + + data class PublishWebviewTelemetry(val event: String) : BrowserMessage } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/LoginBrowser.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/LoginBrowser.kt index eb5693ef92c..1aec1056255 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/LoginBrowser.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/LoginBrowser.kt @@ -404,6 +404,26 @@ abstract class LoginBrowser( return false } + // TODO: should test via handleMessage, however because we can't initiate Q/ToolkitLoginBrowser in test due to jcef not supported in test env + // plus handleMessage is abstract so as a interim, exposing it for testing purpose + @VisibleForTesting + fun publishTelemetry(message: BrowserMessage.PublishWebviewTelemetry) { + val jsonNode = this.objectMapper.readTree(message.event) ?: return + if (jsonNode["metricName"].asText() == "toolkit_didLoadModule") { + val moduleNode = jsonNode["module"] ?: return + val resultNode = jsonNode["result"] ?: return + val result = MetricResult.from(resultNode.asText()) + val reasonNode = jsonNode["reason"] + val durationNode = jsonNode["duration"] + Telemetry.toolkit.didLoadModule.use { span -> + span.module(moduleNode.asText()) + span.result(result) + span.reason(reasonNode?.asText()) + span.duration(durationNode?.asDouble()) + } + } + } + companion object { private val LOG = getLogger() fun getWebviewHTML(webScriptUri: String, query: JBCefJSQuery): String { diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/DevFileUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/DevFileUtils.kt index b9a2a7974c1..f3590d299b9 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/DevFileUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/DevFileUtils.kt @@ -5,6 +5,11 @@ package software.aws.toolkits.jetbrains.utils import com.intellij.openapi.vfs.VirtualFile +fun isDevFile(file: VirtualFile): Boolean = + file.name.matches(Regex("devfile\\.ya?ml", RegexOption.IGNORE_CASE)) + fun isWorkspaceDevFile(file: VirtualFile, addressableRoot: VirtualFile): Boolean = - file.name.matches(Regex("devfile\\.ya?ml", RegexOption.IGNORE_CASE)) && - file.parent?.path == addressableRoot.path + isDevFile(file) && file.parent?.path == addressableRoot.path + +fun getWorkspaceDevFile(addressableRoot: VirtualFile): VirtualFile? = + addressableRoot.children.find { isDevFile(it) } diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt index 0ed59f70c41..ee7108158bc 100644 --- a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt @@ -161,6 +161,30 @@ class BrowserMessageTest { signInOptionClicked = null ) ) + + assertDeserializedInstanceOf( + """ + { + "command": "webviewTelemetry", + "event": "{ \"metricName\": \"foo\" }" + } + """.trimIndent() + ).isEqualTo( + BrowserMessage.PublishWebviewTelemetry( + event = "{ \"metricName\": \"foo\" }" + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "openUrl", + "externalLink": "foo" + } + """ + ).isEqualTo( + BrowserMessage.OpenUrl("foo") + ) } @Test @@ -285,6 +309,14 @@ class BrowserMessageTest { } """ ) + + assertDeserializedWillThrow( + """ + { + "command": "openUrl" + } + """ + ) } @Test diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt new file mode 100644 index 00000000000..aa63ddd71d1 --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt @@ -0,0 +1,161 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ProjectExtension +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +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 +import kotlin.test.assertNotNull + +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 { + private lateinit var sut: TestLoginBrowser + private val project: Project + get() = projectExtension.project + + @JvmField + @RegisterExtension + val mockTelemetryService = MockTelemetryServiceExtension() + + companion object { + @JvmField + @RegisterExtension + val projectExtension = ProjectExtension() + } + + @BeforeEach + fun setup() { + sut = TestLoginBrowser(project) + } + + @Test + fun `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 = firstValue.data.find { it.name == "toolkit_didLoadModule" } + assertNotNull(event) + assertThat(event) + .matches { it.metadata["module"] == "login" } + .matches { it.metadata["result"] == "Succeeded" } + .matches { it.metadata["duration"] == "0.0" } + } + } + + @Test + fun `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 = firstValue.data.find { it.name == "toolkit_didLoadModule" } + assertNotNull(event) + assertThat(event) + .matches { it.metadata["module"] == "login" } + .matches { it.metadata["result"] == "Failed" } + .matches { it.metadata["reason"] == "unexpected error" } + } + } + + @Test + fun `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()) + } + } + + @Test + fun `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/tstFixtures/software/aws/toolkits/jetbrains/core/MockResourceCache.kt b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/core/MockResourceCache.kt index 66d5249ce53..78cfbe4409c 100644 --- a/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/core/MockResourceCache.kt +++ b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/core/MockResourceCache.kt @@ -13,7 +13,6 @@ import org.junit.jupiter.api.extension.BeforeEachCallback import org.junit.jupiter.api.extension.ExtensionContext import org.junit.runner.Description import software.aws.toolkits.core.ClientConnectionSettings -import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.core.credentials.ToolkitAuthenticationProvider import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider @@ -182,8 +181,12 @@ interface MockResourceCacheInterface { addEntry(project, resourceId, CompletableFuture.failedFuture(throws)) } - fun addEntry(connectionSettings: ConnectionSettings, resource: Resource.Cached, value: CompletableFuture) { - addEntry(resource, connectionSettings.region.id, connectionSettings.credentials.id, value) + fun addEntry(connectionSettings: ClientConnectionSettings<*>, resource: Resource.Cached, value: T) { + addEntry(resource, connectionSettings.region.id, connectionSettings.providerId, value) + } + + fun addEntry(connectionSettings: ClientConnectionSettings<*>, resource: Resource.Cached, value: CompletableFuture) { + addEntry(resource, connectionSettings.region.id, connectionSettings.providerId, value) } fun addEntry(resource: Resource.Cached, regionId: String, credentialsId: String, value: T) { diff --git a/plugins/core/jetbrains-ultimate/src-241-243/compat/com/intellij/lang/javascript/JavascriptLanguage.kt b/plugins/core/jetbrains-ultimate/src-242-243/compat/com/intellij/lang/javascript/JavascriptLanguage.kt similarity index 100% rename from plugins/core/jetbrains-ultimate/src-241-243/compat/com/intellij/lang/javascript/JavascriptLanguage.kt rename to plugins/core/jetbrains-ultimate/src-242-243/compat/com/intellij/lang/javascript/JavascriptLanguage.kt diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index e90647fa030..c7e667e7bc8 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -141,9 +141,12 @@ amazonqFeatureDev.placeholder.after_code_generation=Choose an option to proceed amazonqFeatureDev.placeholder.after_monthly_limit=Chat input is disabled amazonqFeatureDev.placeholder.closed_session=Open a new chat tab to continue amazonqFeatureDev.placeholder.context_gathering_complete=Gathering context... +amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts=Downloading and Extracting Lsp Artifacts... amazonqFeatureDev.placeholder.generating_code=Generating code... +amazonqFeatureDev.placeholder.lsp=LSP amazonqFeatureDev.placeholder.new_plan=Describe your task or issue in as much detail as possible amazonqFeatureDev.placeholder.provide_code_feedback=Provide feedback or comments +amazonqFeatureDev.placeholder.select_lsp_artifact=Select LSP Artifact amazonqFeatureDev.placeholder.write_new_prompt=Write a new prompt apprunner.action.configure=Configure Service apprunner.action.create.service=Create Service... @@ -294,6 +297,8 @@ aws.settings.codewhisperer.project_context_index_max_size.tooltip=The maximum si aws.settings.codewhisperer.project_context_index_thread=Workspace index worker threads aws.settings.codewhisperer.project_context_index_thread.tooltip=Number of worker threads of Amazon Q local index process. Set to 0 to use system default worker threads for balanced performance. Please restart or reload IntelliJ after changing worker threads. aws.settings.codewhisperer.warning=To use Amazon Q, login with AWS Builder ID or AWS IAM Identity Center. +aws.settings.codewhisperer.workspace_context=Workspace context +aws.settings.codewhisperer.workspace_context.tooltip=When checked, Amazon Q will enable server side project context. aws.settings.dynamic_resources_configurable.clear_all=Clear All aws.settings.dynamic_resources_configurable.select_all=Select All aws.settings.dynamic_resources_configurable.suggest_types.dialog.message=Please suggest additional AWS resource types (e.g. AWS::S3::Bucket)\nyou would like to see supported in future releases.\n\n(max length: 2000 chars) @@ -1256,7 +1261,7 @@ ecs.service.not_found=Service {0} not found in cluster {1} ecs.task_definition.json_schema_name=AWS ECS Task Definition ecs.task_definitions=Task Definitions environment.variables.dialog.title=Environment Variables -executableCommon.auto_managed=Managed by AWS Toolkit +executableCommon.auto_managed=Managed by AWS executableCommon.auto_resolved=Auto-detected: {0} executableCommon.cli_not_configured={0} executable not configured executableCommon.configurable.title=External Tools diff --git a/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/paginators-1.json b/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/paginators-1.json index c0860a2f5a5..381687933c1 100644 --- a/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/paginators-1.json +++ b/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/paginators-1.json @@ -11,9 +11,21 @@ "limit_key": "maxResults", "result_key": "customizations" }, + "ListAvailableProfiles": { + "input_token": "nextToken", + "output_token": "nextToken", + "limit_key": "maxResults", + "result_key": "profiles" + }, "ListCodeAnalysisFindings": { "input_token": "nextToken", "output_token": "nextToken" + }, + "ListWorkspaceMetadata": { + "input_token": "nextToken", + "output_token": "nextToken", + "limit_key": "maxResults", + "result_key": "workspaces" } } } diff --git a/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json b/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json index 1e7898a48d7..778dbff05d9 100644 --- a/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json +++ b/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json @@ -28,7 +28,8 @@ {"shape":"InternalServerException"}, {"shape":"ValidationException"}, {"shape":"AccessDeniedException"} - ] + ], + "documentation":"

API to export operation result as an archive

" }, "GenerateAssistantResponse":{ "name":"GenerateAssistantResponse", @@ -43,7 +44,8 @@ {"shape":"InternalServerException"}, {"shape":"ValidationException"}, {"shape":"AccessDeniedException"} - ] + ], + "documentation":"

API to generate assistant response.

" }, "GenerateTaskAssistPlan":{ "name":"GenerateTaskAssistPlan", @@ -55,8 +57,28 @@ "output":{"shape":"GenerateTaskAssistPlanResponse"}, "errors":[ {"shape":"ThrottlingException"}, + {"shape":"ConflictException"}, {"shape":"ServiceQuotaExceededException"}, + {"shape":"ResourceNotFoundException"}, + {"shape":"InternalServerException"}, + {"shape":"ValidationException"}, + {"shape":"AccessDeniedException"} + ], + "documentation":"

API to generate task assist plan.

" + }, + "SendMessage":{ + "name":"SendMessage", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"SendMessageRequest"}, + "output":{"shape":"SendMessageResponse"}, + "errors":[ + {"shape":"DryRunOperationException"}, + {"shape":"ThrottlingException"}, {"shape":"ConflictException"}, + {"shape":"ServiceQuotaExceededException"}, {"shape":"ResourceNotFoundException"}, {"shape":"InternalServerException"}, {"shape":"ValidationException"}, @@ -69,10 +91,157 @@ "type":"structure", "required":["message"], "members":{ - "message":{"shape":"String"} + "message":{"shape":"String"}, + "reason":{"shape":"AccessDeniedExceptionReason"} }, + "documentation":"

This exception is thrown when the user does not have sufficient access to perform this action.

", "exception":true }, + "AccessDeniedExceptionReason":{ + "type":"string", + "documentation":"

Reason for AccessDeniedException

", + "enum":["UNAUTHORIZED_CUSTOMIZATION_RESOURCE_ACCESS"] + }, + "Action":{ + "type":"structure", + "members":{ + "webLink":{"shape":"WebLink"}, + "moduleLink":{"shape":"ModuleLink"} + } + }, + "AdditionalContentEntry":{ + "type":"structure", + "required":[ + "name", + "description" + ], + "members":{ + "name":{ + "shape":"AdditionalContentEntryNameString", + "documentation":"

The name/identifier for this context entry

" + }, + "description":{ + "shape":"AdditionalContentEntryDescriptionString", + "documentation":"

A description of what this context entry represents

" + }, + "innerContext":{ + "shape":"AdditionalContentEntryInnerContextString", + "documentation":"

The actual contextual content

" + } + }, + "documentation":"

Structure representing a single entry of additional contextual content

" + }, + "AdditionalContentEntryDescriptionString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "AdditionalContentEntryInnerContextString":{ + "type":"string", + "max":8192, + "min":1, + "sensitive":true + }, + "AdditionalContentEntryNameString":{ + "type":"string", + "max":1024, + "min":1, + "pattern":"[a-z]+(?:-[a-z0-9]+)*", + "sensitive":true + }, + "AdditionalContentList":{ + "type":"list", + "member":{"shape":"AdditionalContentEntry"}, + "documentation":"

A list of additional content entries, limited to 20 items

", + "max":20, + "min":0 + }, + "Alert":{ + "type":"structure", + "required":[ + "type", + "content" + ], + "members":{ + "type":{"shape":"AlertType"}, + "content":{ + "shape":"AlertComponentList", + "documentation":"

Contains the content of the alert, which may include sensitive information.

" + } + }, + "documentation":"

Structure representing an alert with a type and content.

" + }, + "AlertComponent":{ + "type":"structure", + "members":{ + "text":{"shape":"Text"} + } + }, + "AlertComponentList":{ + "type":"list", + "member":{"shape":"AlertComponent"} + }, + "AlertType":{ + "type":"string", + "documentation":"

Enum defining types of alerts that can be issued.

", + "enum":[ + "INFO", + "ERROR", + "WARNING" + ] + }, + "AppStudioState":{ + "type":"structure", + "required":[ + "namespace", + "propertyName", + "propertyContext" + ], + "members":{ + "namespace":{ + "shape":"AppStudioStateNamespaceString", + "documentation":"

The namespace of the context. Examples: 'ui.Button', 'ui.Table.DataSource', 'ui.Table.RowActions.Button', 'logic.invokeAWS', 'logic.JavaScript'

" + }, + "propertyName":{ + "shape":"AppStudioStatePropertyNameString", + "documentation":"

The name of the property. Examples: 'visibility', 'disability', 'value', 'code'

" + }, + "propertyValue":{ + "shape":"AppStudioStatePropertyValueString", + "documentation":"

The value of the property.

" + }, + "propertyContext":{ + "shape":"AppStudioStatePropertyContextString", + "documentation":"

Context about how the property is used

" + } + }, + "documentation":"

Description of a user's context when they are calling Q Chat from AppStudio

" + }, + "AppStudioStateNamespaceString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "AppStudioStatePropertyContextString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "AppStudioStatePropertyNameString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "AppStudioStatePropertyValueString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true + }, "ArtifactId":{ "type":"string", "max":126, @@ -83,8 +252,12 @@ "type":"structure", "required":["content"], "members":{ - "content":{"shape":"AssistantResponseEventContentString"} + "content":{ + "shape":"AssistantResponseEventContentString", + "documentation":"

The content of the text message in markdown format.

" + } }, + "documentation":"

Streaming Response Event for Assistant Markdown text message.

", "event":true }, "AssistantResponseEventContentString":{ @@ -97,32 +270,57 @@ "type":"structure", "required":["content"], "members":{ - "messageId":{"shape":"AssistantResponseMessageMessageIdString"}, - "content":{"shape":"AssistantResponseMessageContentString"}, - "supplementaryWebLinks":{"shape":"SupplementaryWebLinks"}, - "references":{"shape":"References"}, - "followupPrompt":{"shape":"FollowupPrompt"} - } + "messageId":{"shape":"MessageId"}, + "content":{ + "shape":"AssistantResponseMessageContentString", + "documentation":"

The content of the text message in markdown format.

" + }, + "supplementaryWebLinks":{ + "shape":"SupplementaryWebLinks", + "documentation":"

Web References

" + }, + "references":{ + "shape":"References", + "documentation":"

Code References

" + }, + "followupPrompt":{ + "shape":"FollowupPrompt", + "documentation":"

Followup Prompt

" + }, + "toolUses":{ + "shape":"ToolUses", + "documentation":"

ToolUse Request

" + } + }, + "documentation":"

Markdown text message.

" }, "AssistantResponseMessageContentString":{ "type":"string", - "max":4096, + "max":100000, "min":0, "sensitive":true }, - "AssistantResponseMessageMessageIdString":{ - "type":"string", - "max":128, - "min":0 - }, "BinaryMetadataEvent":{ "type":"structure", "members":{ - "size":{"shape":"Long"}, - "mimeType":{"shape":"String"}, - "contentChecksum":{"shape":"ContentChecksum"}, - "contentChecksumType":{"shape":"ContentChecksumType"} + "size":{ + "shape":"Long", + "documentation":"

Content length of the binary payload

" + }, + "mimeType":{ + "shape":"String", + "documentation":"

Content type of the response

" + }, + "contentChecksum":{ + "shape":"ContentChecksum", + "documentation":"

Content checksum of the binary payload

" + }, + "contentChecksumType":{ + "shape":"ContentChecksumType", + "documentation":"

Content checksum type of the binary payload

" + } }, + "documentation":"

Payload Part

", "event":true, "sensitive":true }, @@ -131,6 +329,7 @@ "members":{ "bytes":{"shape":"PartBody"} }, + "documentation":"

Payload Part

", "event":true, "sensitive":true }, @@ -141,7 +340,8 @@ "ChatHistory":{ "type":"list", "member":{"shape":"ChatMessage"}, - "max":10, + "documentation":"

Indicates Participant in Chat conversation

", + "max":250, "min":0 }, "ChatMessage":{ @@ -155,39 +355,230 @@ "ChatResponseStream":{ "type":"structure", "members":{ - "messageMetadataEvent":{"shape":"MessageMetadataEvent"}, - "assistantResponseEvent":{"shape":"AssistantResponseEvent"}, - "codeReferenceEvent":{"shape":"CodeReferenceEvent"}, - "supplementaryWebLinksEvent":{"shape":"SupplementaryWebLinksEvent"}, - "followupPromptEvent":{"shape":"FollowupPromptEvent"}, - "invalidStateEvent": {"shape": "InvalidStateEvent"}, - "error":{"shape":"InternalServerException"} + "messageMetadataEvent":{ + "shape":"MessageMetadataEvent", + "documentation":"

Message Metadata event

" + }, + "assistantResponseEvent":{ + "shape":"AssistantResponseEvent", + "documentation":"

Assistant response event - Text / Code snippet

" + }, + "dryRunSucceedEvent":{ + "shape":"DryRunSucceedEvent", + "documentation":"

DryRun Succeed Event

" + }, + "codeReferenceEvent":{ + "shape":"CodeReferenceEvent", + "documentation":"

Code References event

" + }, + "supplementaryWebLinksEvent":{ + "shape":"SupplementaryWebLinksEvent", + "documentation":"

Web Reference links event

" + }, + "followupPromptEvent":{ + "shape":"FollowupPromptEvent", + "documentation":"

Followup prompt event

" + }, + "codeEvent":{ + "shape":"CodeEvent", + "documentation":"

Code Generated event

" + }, + "intentsEvent":{ + "shape":"IntentsEvent", + "documentation":"

Intents event

" + }, + "interactionComponentsEvent":{ + "shape":"InteractionComponentsEvent", + "documentation":"

Interactions components event

" + }, + "toolUseEvent":{ + "shape":"ToolUseEvent", + "documentation":"

ToolUse event

" + }, + "toolResultEvent":{ + "shape":"ToolResultEvent", + "documentation":"

Tool use result

" + }, + "citationEvent":{ + "shape":"CitationEvent", + "documentation":"

Citation event

" + }, + "invalidStateEvent":{ + "shape":"InvalidStateEvent", + "documentation":"

Invalid State event

" + }, + "error":{ + "shape":"InternalServerException", + "documentation":"

Internal Server Exception

" + } }, + "documentation":"

Streaming events from UniDirectional Streaming Conversational APIs.

", "eventstream":true }, "ChatTriggerType":{ "type":"string", + "documentation":"

Trigger Reason for Chat

", "enum":[ "MANUAL", "DIAGNOSTIC", "INLINE_CHAT" ] }, + "CitationEvent":{ + "type":"structure", + "required":[ + "target", + "citationLink" + ], + "members":{ + "target":{ + "shape":"CitationTarget", + "documentation":"

The position or the range of the response text to be cited

" + }, + "citationText":{ + "shape":"SensitiveString", + "documentation":"

The text inside the citation '1' in [1]

" + }, + "citationLink":{ + "shape":"SensitiveString", + "documentation":"

The link to the document being cited

" + } + }, + "documentation":"

Streaming response event for citations

", + "event":true + }, + "CitationTarget":{ + "type":"structure", + "members":{ + "location":{ + "shape":"Offset", + "documentation":"

Represents a position in the response text where a citation should be added

" + }, + "range":{ + "shape":"Span", + "documentation":"

Represents the range in the response text to be targetted by a citation

" + } + }, + "documentation":"

Represents the target of a citation event

", + "union":true + }, + "CloudWatchTroubleshootingLink":{ + "type":"structure", + "required":[ + "label", + "investigationPayload" + ], + "members":{ + "label":{ + "shape":"CloudWatchTroubleshootingLinkLabelString", + "documentation":"

A label for the link.

" + }, + "investigationPayload":{ + "shape":"CloudWatchTroubleshootingLinkInvestigationPayloadString", + "documentation":"

Stringified JSON payload. See spec here https://code.amazon.com/packages/CloudWatchOdysseyModel/blobs/50c0832f0e393e4ab68827eb4f04d832366821c1/--/model/events.smithy#L28 .

" + }, + "defaultText":{ + "shape":"CloudWatchTroubleshootingLinkDefaultTextString", + "documentation":"

Fallback string, if target channel does not support the CloudWatchTroubleshootingLink.

" + } + }, + "documentation":"

For CloudWatch Troubleshooting Link Module

" + }, + "CloudWatchTroubleshootingLinkDefaultTextString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "CloudWatchTroubleshootingLinkInvestigationPayloadString":{ + "type":"string", + "max":16384, + "min":0, + "sensitive":true + }, + "CloudWatchTroubleshootingLinkLabelString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "CodeDescription":{ + "type":"structure", + "required":["href"], + "members":{ + "href":{ + "shape":"CodeDescriptionHrefString", + "documentation":"

An URI to open with more information about the diagnostic error.

" + } + }, + "documentation":"

Structure to capture a description for an error code.

" + }, + "CodeDescriptionHrefString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "CodeEvent":{ + "type":"structure", + "required":["content"], + "members":{ + "content":{ + "shape":"CodeEventContentString", + "documentation":"

Generated code snippet.

" + } + }, + "documentation":"

Streaming response event for generated code text.

", + "event":true + }, + "CodeEventContentString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true + }, "CodeReferenceEvent":{ "type":"structure", "members":{ - "references":{"shape":"References"} + "references":{ + "shape":"References", + "documentation":"

Code References for Assistant Response Message

" + } }, + "documentation":"

Streaming Response Event for CodeReferences

", "event":true }, "ConflictException":{ "type":"structure", "required":["message"], "members":{ - "message":{"shape":"String"} + "message":{"shape":"String"}, + "reason":{"shape":"ConflictExceptionReason"} }, + "documentation":"

This exception is thrown when the action to perform could not be completed because the resource is in a conflicting state.

", "exception":true }, + "ConflictExceptionReason":{ + "type":"string", + "documentation":"

Reason for ConflictException

", + "enum":[ + "CUSTOMER_KMS_KEY_INVALID_KEY_POLICY", + "CUSTOMER_KMS_KEY_DISABLED", + "MISMATCHED_KMS_KEY" + ] + }, + "ConsoleState":{ + "type":"structure", + "members":{ + "region":{"shape":"String"}, + "consoleUrl":{"shape":"SensitiveString"}, + "serviceId":{"shape":"String"}, + "serviceConsolePage":{"shape":"String"}, + "serviceSubconsolePage":{"shape":"String"}, + "taskName":{"shape":"SensitiveString"} + }, + "documentation":"

Information about the state of the AWS management console page from which the user is calling

" + }, "ContentChecksum":{ "type":"string", "max":512, @@ -199,6 +590,7 @@ }, "ContextTruncationScheme":{ "type":"string", + "documentation":"

Workspace context truncation schemes based on usecase

", "enum":[ "ANALYSIS", "GUMBY" @@ -206,6 +598,7 @@ }, "ConversationId":{ "type":"string", + "documentation":"

ID which represents a multi-turn conversation

", "max":128, "min":1 }, @@ -216,31 +609,108 @@ "chatTriggerType" ], "members":{ - "conversationId":{"shape":"ConversationId"}, - "history":{"shape":"ChatHistory"}, - "currentMessage":{"shape":"ChatMessage"}, - "chatTriggerType":{"shape":"ChatTriggerType"}, - "customizationArn":{"shape": "ResourceArn"} - } + "conversationId":{ + "shape":"ConversationId", + "documentation":"

Unique identifier for the chat conversation stream

" + }, + "history":{ + "shape":"ChatHistory", + "documentation":"

Holds the history of chat messages.

" + }, + "currentMessage":{ + "shape":"ChatMessage", + "documentation":"

Holds the current message being processed or displayed.

" + }, + "chatTriggerType":{ + "shape":"ChatTriggerType", + "documentation":"

Trigger Reason for Chat

" + }, + "customizationArn":{"shape":"ResourceArn"} + }, + "documentation":"

Structure to represent the current state of a chat conversation.

" }, "CursorState":{ "type":"structure", "members":{ - "position":{"shape":"Position"}, - "range":{"shape":"Range"} + "position":{ + "shape":"Position", + "documentation":"

Represents a cursor position in a Text Document

" + }, + "range":{ + "shape":"Range", + "documentation":"

Represents a text selection in a Text Document

" + } }, + "documentation":"

Represents the state of the Cursor in an Editor

", "union":true }, "Diagnostic":{ "type":"structure", "members":{ - "textDocumentDiagnostic":{"shape":"TextDocumentDiagnostic"}, - "runtimeDiagnostic":{"shape":"RuntimeDiagnostic"} + "textDocumentDiagnostic":{ + "shape":"TextDocumentDiagnostic", + "documentation":"

Diagnostics originating from a TextDocument

" + }, + "runtimeDiagnostic":{ + "shape":"RuntimeDiagnostic", + "documentation":"

Diagnostics originating from a Runtime

" + } }, + "documentation":"

Represents a Diagnostic message

", "union":true }, + "DiagnosticLocation":{ + "type":"structure", + "required":[ + "uri", + "range" + ], + "members":{ + "uri":{"shape":"DiagnosticLocationUriString"}, + "range":{"shape":"Range"} + }, + "documentation":"

Represents a location inside a resource, such as a line inside a text file.

" + }, + "DiagnosticLocationUriString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "DiagnosticRelatedInformation":{ + "type":"structure", + "required":[ + "location", + "message" + ], + "members":{ + "location":{ + "shape":"DiagnosticLocation", + "documentation":"

The location of this related diagnostic information.

" + }, + "message":{ + "shape":"DiagnosticRelatedInformationMessageString", + "documentation":"

The message of this related diagnostic information.

" + } + }, + "documentation":"

Represents a related message and source code location for a diagnostic.

" + }, + "DiagnosticRelatedInformationList":{ + "type":"list", + "member":{"shape":"DiagnosticRelatedInformation"}, + "documentation":"

List of DiagnosticRelatedInformation

", + "max":1024, + "min":0 + }, + "DiagnosticRelatedInformationMessageString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, "DiagnosticSeverity":{ "type":"string", + "documentation":"

Diagnostic Error types

", "enum":[ "ERROR", "WARNING", @@ -248,6 +718,21 @@ "HINT" ] }, + "DiagnosticTag":{ + "type":"string", + "documentation":"

The diagnostic tags.

", + "enum":[ + "UNNECESSARY", + "DEPRECATED" + ] + }, + "DiagnosticTagList":{ + "type":"list", + "member":{"shape":"DiagnosticTag"}, + "documentation":"

List of DiagnosticTag

", + "max":1024, + "min":0 + }, "DocumentSymbol":{ "type":"structure", "required":[ @@ -255,9 +740,18 @@ "type" ], "members":{ - "name":{"shape":"DocumentSymbolNameString"}, - "type":{"shape":"SymbolType"}, - "source":{"shape":"DocumentSymbolSourceString"} + "name":{ + "shape":"DocumentSymbolNameString", + "documentation":"

Name of the Document Symbol

" + }, + "type":{ + "shape":"SymbolType", + "documentation":"

Symbol type - DECLARATION / USAGE

" + }, + "source":{ + "shape":"DocumentSymbolSourceString", + "documentation":"

Symbol package / source for FullyQualified names

" + } } }, "DocumentSymbolNameString":{ @@ -276,14 +770,120 @@ "max":1000, "min":0 }, + "DryRunOperationException":{ + "type":"structure", + "members":{ + "message":{"shape":"String"}, + "responseCode":{"shape":"Integer"} + }, + "documentation":"

This exception is translated to a 204 as it succeeded the IAM Auth.

", + "exception":true + }, + "DryRunSucceedEvent":{ + "type":"structure", + "members":{ + }, + "documentation":"

Streaming Response Event when DryRun is succeessful

", + "event":true + }, "EditorState":{ "type":"structure", "members":{ - "document":{"shape":"TextDocument"}, - "cursorState":{"shape":"CursorState"}, - "relevantDocuments": {"shape": "RelevantDocumentList"}, - "useRelevantDocuments": {"shape": "Boolean"} - } + "document":{ + "shape":"TextDocument", + "documentation":"

Represents currently edited file

" + }, + "cursorState":{ + "shape":"CursorState", + "documentation":"

Position of the cursor

" + }, + "relevantDocuments":{ + "shape":"RelevantDocumentList", + "documentation":"

Represents IDE provided relevant files

" + }, + "useRelevantDocuments":{ + "shape":"Boolean", + "documentation":"

Whether service should use relevant document in prompt

" + }, + "workspaceFolders":{ + "shape":"WorkspaceFolderList", + "documentation":"

Represents IDE provided list of workspace folders

" + } + }, + "documentation":"

Represents the state of an Editor

" + }, + "EnvState":{ + "type":"structure", + "members":{ + "operatingSystem":{ + "shape":"EnvStateOperatingSystemString", + "documentation":"

The name of the operating system in use

" + }, + "currentWorkingDirectory":{ + "shape":"EnvStateCurrentWorkingDirectoryString", + "documentation":"

The current working directory of the environment

" + }, + "environmentVariables":{ + "shape":"EnvironmentVariables", + "documentation":"

The environment variables set in the current environment

" + }, + "timezoneOffset":{ + "shape":"EnvStateTimezoneOffsetInteger", + "documentation":"

Local timezone offset of the client. For more information, see documentation https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTimezoneOffset

" + } + }, + "documentation":"

State related to the user's environment

" + }, + "EnvStateCurrentWorkingDirectoryString":{ + "type":"string", + "max":256, + "min":1, + "sensitive":true + }, + "EnvStateOperatingSystemString":{ + "type":"string", + "max":32, + "min":1, + "pattern":"(macos|linux|windows)" + }, + "EnvStateTimezoneOffsetInteger":{ + "type":"integer", + "box":true, + "max":1440, + "min":-1440 + }, + "EnvironmentVariable":{ + "type":"structure", + "members":{ + "key":{ + "shape":"EnvironmentVariableKeyString", + "documentation":"

The key of an environment variable

" + }, + "value":{ + "shape":"EnvironmentVariableValueString", + "documentation":"

The value of an environment variable

" + } + }, + "documentation":"

An environment variable

" + }, + "EnvironmentVariableKeyString":{ + "type":"string", + "max":256, + "min":1, + "sensitive":true + }, + "EnvironmentVariableValueString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "EnvironmentVariables":{ + "type":"list", + "member":{"shape":"EnvironmentVariable"}, + "documentation":"

A list of environment variables

", + "max":100, + "min":0 }, "ExportContext":{ "type":"structure", @@ -291,10 +891,12 @@ "transformationExportContext":{"shape":"TransformationExportContext"}, "unitTestGenerationExportContext":{"shape":"UnitTestGenerationExportContext"} }, + "documentation":"

Export Context

", "union":true }, "ExportIntent":{ "type":"string", + "documentation":"

Export Intent

", "enum":[ "TRANSFORMATION", "TASK_ASSIST", @@ -308,25 +910,40 @@ "exportIntent" ], "members":{ - "exportId":{"shape":"String"}, + "exportId":{"shape":"ExportResultArchiveRequestExportIdString"}, "exportIntent":{"shape":"ExportIntent"}, - "exportContext":{"shape":"ExportContext"} - } + "exportContext":{"shape":"ExportContext"}, + "profileArn":{"shape":"ProfileArn"} + }, + "documentation":"

Structure to represent a new ExportResultArchive request.

" + }, + "ExportResultArchiveRequestExportIdString":{ + "type":"string", + "max":1024, + "min":0 }, "ExportResultArchiveResponse":{ "type":"structure", "required":["body"], "members":{ "body":{"shape":"ResultArchiveStream"} - } + }, + "documentation":"

Structure to represent ExportResultArchive response.

" }, "FollowupPrompt":{ "type":"structure", "required":["content"], "members":{ - "content":{"shape":"FollowupPromptContentString"}, - "userIntent":{"shape":"UserIntent"} - } + "content":{ + "shape":"FollowupPromptContentString", + "documentation":"

The content of the text message in markdown format.

" + }, + "userIntent":{ + "shape":"UserIntent", + "documentation":"

User Intent

" + } + }, + "documentation":"

Followup Prompt for the Assistant Response

" }, "FollowupPromptContentString":{ "type":"string", @@ -339,14 +956,17 @@ "members":{ "followupPrompt":{"shape":"FollowupPrompt"} }, + "documentation":"

Streaming Response Event for Followup Prompt.

", "event":true }, "GenerateAssistantResponseRequest":{ "type":"structure", "required":["conversationState"], "members":{ - "conversationState":{"shape":"ConversationState"} - } + "conversationState":{"shape":"ConversationState"}, + "profileArn":{"shape":"ProfileArn"} + }, + "documentation":"

Structure to represent a new generate assistant response request.

" }, "GenerateAssistantResponseResponse":{ "type":"structure", @@ -357,7 +977,8 @@ "members":{ "conversationId":{"shape":"ConversationId"}, "generateAssistantResponseResponse":{"shape":"ChatResponseStream"} - } + }, + "documentation":"

Structure to represent generate assistant response response.

" }, "GenerateTaskAssistPlanRequest":{ "type":"structure", @@ -367,50 +988,270 @@ ], "members":{ "conversationState":{"shape":"ConversationState"}, - "workspaceState":{"shape":"WorkspaceState"} - } + "workspaceState":{"shape":"WorkspaceState"}, + "profileArn":{"shape":"ProfileArn"} + }, + "documentation":"

Structure to represent execute planning interaction request.

" }, "GenerateTaskAssistPlanResponse":{ "type":"structure", "members":{ "planningResponseStream":{"shape":"ChatResponseStream"} - } + }, + "documentation":"

Structure to represent execute planning interaction response.

" + }, + "GitState":{ + "type":"structure", + "members":{ + "status":{ + "shape":"GitStateStatusString", + "documentation":"

The output of the command git status --porcelain=v1 -b

" + } + }, + "documentation":"

State related to the Git VSC

" + }, + "GitStateStatusString":{ + "type":"string", + "max":4096, + "min":0, + "sensitive":true + }, + "ImageBlock":{ + "type":"structure", + "required":[ + "format", + "source" + ], + "members":{ + "format":{"shape":"ImageFormat"}, + "source":{"shape":"ImageSource"} + }, + "documentation":"

Represents the image source itself and the format of the image.

" + }, + "ImageBlocks":{ + "type":"list", + "member":{"shape":"ImageBlock"}, + "max":10, + "min":0 + }, + "ImageFormat":{ + "type":"string", + "enum":[ + "png", + "jpeg", + "gif", + "webp" + ] + }, + "ImageSource":{ + "type":"structure", + "members":{ + "bytes":{"shape":"ImageSourceBytesBlob"} + }, + "documentation":"

Image bytes limited to ~10MB considering overhead of base64 encoding

", + "sensitive":true, + "union":true + }, + "ImageSourceBytesBlob":{ + "type":"blob", + "max":1500000, + "min":1 + }, + "InfrastructureUpdate":{ + "type":"structure", + "members":{ + "transition":{"shape":"InfrastructureUpdateTransition"} + }, + "documentation":"

Structure representing different types of infrastructure updates.

" + }, + "InfrastructureUpdateTransition":{ + "type":"structure", + "required":[ + "currentState", + "nextState" + ], + "members":{ + "currentState":{ + "shape":"InfrastructureUpdateTransitionCurrentStateString", + "documentation":"

The current state of the infrastructure before the update.

" + }, + "nextState":{ + "shape":"InfrastructureUpdateTransitionNextStateString", + "documentation":"

The next state of the infrastructure following the update.

" + } + }, + "documentation":"

Structure describing a transition between two states in an infrastructure update.

" + }, + "InfrastructureUpdateTransitionCurrentStateString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true + }, + "InfrastructureUpdateTransitionNextStateString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true }, "Integer":{ "type":"integer", "box":true }, + "IntentData":{ + "type":"map", + "key":{"shape":"String"}, + "value":{"shape":"IntentDataType"}, + "max":100, + "min":0, + "sensitive":true + }, + "IntentDataType":{ + "type":"structure", + "members":{ + "string":{"shape":"String"} + }, + "union":true + }, + "IntentMap":{ + "type":"map", + "key":{"shape":"IntentType"}, + "value":{"shape":"IntentData"}, + "max":5, + "min":0 + }, + "IntentType":{ + "type":"string", + "enum":[ + "SUPPORT", + "GLUE_SENSEI", + "RESOURCE_DATA" + ] + }, + "IntentsEvent":{ + "type":"structure", + "members":{ + "intents":{ + "shape":"IntentMap", + "documentation":"

A map of Intent objects

" + } + }, + "documentation":"

Streaming Response Event for Intents

", + "event":true + }, + "InteractionComponent":{ + "type":"structure", + "members":{ + "text":{"shape":"Text"}, + "alert":{"shape":"Alert"}, + "infrastructureUpdate":{"shape":"InfrastructureUpdate"}, + "progress":{"shape":"Progress"}, + "step":{"shape":"Step"}, + "taskDetails":{"shape":"TaskDetails"}, + "taskReference":{"shape":"TaskReference"}, + "suggestions":{"shape":"Suggestions"}, + "section":{"shape":"Section"}, + "resource":{"shape":"Resource"}, + "resourceList":{"shape":"ResourceList"}, + "action":{"shape":"Action"} + }, + "documentation":"

Structure representing different types of interaction components.

" + }, + "InteractionComponentEntry":{ + "type":"structure", + "required":["interactionComponent"], + "members":{ + "interactionComponentId":{ + "shape":"InteractionComponentId", + "documentation":"

Identifier that can uniquely identify the interaction component within stream response. This field is optional.

" + }, + "interactionComponent":{ + "shape":"InteractionComponent", + "documentation":"

Interaction component

" + } + }, + "documentation":"

Interaction component with an identifier

" + }, + "InteractionComponentEntryList":{ + "type":"list", + "member":{"shape":"InteractionComponentEntry"}, + "documentation":"

List of identifiable interaction components

" + }, + "InteractionComponentId":{ + "type":"string", + "documentation":"

Unique identifier for interaction component

", + "max":128, + "min":0 + }, + "InteractionComponentsEvent":{ + "type":"structure", + "required":["interactionComponentEntries"], + "members":{ + "interactionComponentEntries":{ + "shape":"InteractionComponentEntryList", + "documentation":"

List of identifiable interaction components

" + } + }, + "documentation":"

Streaming Event for interaction components list

", + "event":true + }, "InternalServerException":{ "type":"structure", "required":["message"], "members":{ "message":{"shape":"String"} }, + "documentation":"

This exception is thrown when an unexpected error occurred during the processing of a request.

", "exception":true, "fault":true, "retryable":{"throttling":false} }, "InvalidStateEvent":{ "type":"structure", + "required":[ + "reason", + "message" + ], "members":{ "reason":{"shape":"InvalidStateReason"}, - "message":{"shape": "String"} + "message":{"shape":"InvalidStateEventMessageString"} }, + "documentation":"

Streaming Response Event when an Invalid State is reached

", "event":true }, + "InvalidStateEventMessageString":{ + "type":"string", + "max":10240, + "min":0 + }, "InvalidStateReason":{ "type":"string", + "documentation":"

Reasons for Invalid State Event

", "enum":["INVALID_TASK_ASSIST_PLAN"] }, "Long":{ "type":"long", "box":true }, + "MessageId":{ + "type":"string", + "documentation":"

Unique identifier for the chat message

", + "max":128, + "min":0 + }, "MessageMetadataEvent":{ "type":"structure", "members":{ - "conversationId":{"shape":"MessageMetadataEventConversationIdString"} + "conversationId":{ + "shape":"MessageMetadataEventConversationIdString", + "documentation":"

Unique identifier for the conversation

" + }, + "utteranceId":{ + "shape":"MessageMetadataEventUtteranceIdString", + "documentation":"

Unique identifier for the utterance

" + } }, + "documentation":"

Streaming Response Event for AssistantResponse Metadata

", "event":true }, "MessageMetadataEventConversationIdString":{ @@ -418,8 +1259,47 @@ "max":128, "min":0 }, + "MessageMetadataEventUtteranceIdString":{ + "type":"string", + "max":128, + "min":0 + }, + "ModuleLink":{ + "type":"structure", + "members":{ + "cloudWatchTroubleshootingLink":{"shape":"CloudWatchTroubleshootingLink"} + } + }, + "Offset":{ + "type":"integer", + "documentation":"

Offset in the response text

", + "box":true, + "min":0 + }, + "Origin":{ + "type":"string", + "documentation":"

Enum to represent the origin application conversing with Sidekick.

", + "enum":[ + "CHATBOT", + "CONSOLE", + "DOCUMENTATION", + "MARKETING", + "MOBILE", + "SERVICE_INTERNAL", + "UNIFIED_SEARCH", + "UNKNOWN", + "MD", + "IDE", + "SAGE_MAKER", + "CLI", + "AI_EDITOR", + "OPENSEARCH_DASHBOARD", + "GITLAB" + ] + }, "PartBody":{ "type":"blob", + "documentation":"

Payload Part's body

", "max":1000000, "min":0, "sensitive":true @@ -431,22 +1311,57 @@ "character" ], "members":{ - "line":{"shape":"Integer"}, - "character":{"shape":"Integer"} - } + "line":{ + "shape":"Integer", + "documentation":"

Line position in a document.

" + }, + "character":{ + "shape":"Integer", + "documentation":"

Character offset on a line in a document (zero-based)

" + } + }, + "documentation":"

Indicates Cursor postion in a Text Document

" + }, + "ProfileArn":{ + "type":"string", + "max":950, + "min":0, + "pattern":"arn:aws:codewhisperer:[-.a-z0-9]{1,63}:\\d{12}:profile/([a-zA-Z0-9]){12}" }, "ProgrammingLanguage":{ "type":"structure", "required":["languageName"], "members":{ "languageName":{"shape":"ProgrammingLanguageLanguageNameString"} - } + }, + "documentation":"

Programming Languages supported by CodeWhisperer

" }, "ProgrammingLanguageLanguageNameString":{ "type":"string", "max":128, "min":1, - "pattern":"(python|javascript|java|csharp|typescript|c|cpp|go|kotlin|php|ruby|rust|scala|shell|sql|json|yaml|vue|tf)" + "pattern":"(python|javascript|java|csharp|typescript|c|cpp|go|kotlin|php|ruby|rust|scala|shell|sql|json|yaml|vue|tf|tsx|jsx|plaintext|systemverilog|dart|lua|swift|powershell|r)" + }, + "Progress":{ + "type":"structure", + "required":["content"], + "members":{ + "content":{ + "shape":"ProgressComponentList", + "documentation":"

A collection of steps that make up a process. Each step is detailed using the Step structure.

" + } + }, + "documentation":"

Structure representing a collection of steps in a process.

" + }, + "ProgressComponent":{ + "type":"structure", + "members":{ + "step":{"shape":"Step"} + } + }, + "ProgressComponentList":{ + "type":"list", + "member":{"shape":"ProgressComponent"} }, "Range":{ "type":"structure", @@ -455,18 +1370,38 @@ "end" ], "members":{ - "start":{"shape":"Position"}, - "end":{"shape":"Position"} - } + "start":{ + "shape":"Position", + "documentation":"

The range's start position.

" + }, + "end":{ + "shape":"Position", + "documentation":"

The range's end position.

" + } + }, + "documentation":"

Indicates Range / Span in a Text Document

" }, "Reference":{ "type":"structure", "members":{ - "licenseName":{"shape":"ReferenceLicenseNameString"}, - "repository":{"shape":"ReferenceRepositoryString"}, - "url":{"shape":"ReferenceUrlString"}, - "recommendationContentSpan":{"shape":"Span"} - } + "licenseName":{ + "shape":"ReferenceLicenseNameString", + "documentation":"

License name

" + }, + "repository":{ + "shape":"ReferenceRepositoryString", + "documentation":"

Code Repsitory for the associated reference

" + }, + "url":{ + "shape":"ReferenceUrlString", + "documentation":"

Respository URL

" + }, + "recommendationContentSpan":{ + "shape":"Span", + "documentation":"

Span / Range for the Reference

" + } + }, + "documentation":"

Code Reference / Repository details

" }, "ReferenceLicenseNameString":{ "type":"string", @@ -489,23 +1424,90 @@ "max":10, "min":0 }, - "RelevantTextDocument": { - "type": "structure", - "required": [ - "relativeFilePath" + "RelevantDocumentList":{ + "type":"list", + "member":{"shape":"RelevantTextDocument"}, + "max":30, + "min":0 + }, + "RelevantTextDocument":{ + "type":"structure", + "required":["relativeFilePath"], + "members":{ + "relativeFilePath":{ + "shape":"RelevantTextDocumentRelativeFilePathString", + "documentation":"

Filepath relative to the root of the workspace

" + }, + "programmingLanguage":{ + "shape":"ProgrammingLanguage", + "documentation":"

The text document's language identifier.

" + }, + "text":{ + "shape":"RelevantTextDocumentTextString", + "documentation":"

Content of the text document

" + }, + "documentSymbols":{ + "shape":"DocumentSymbols", + "documentation":"

DocumentSymbols parsed from a text document

" + } + }, + "documentation":"

Represents an IDE retrieved relevant Text Document / File

" + }, + "RelevantTextDocumentRelativeFilePathString":{ + "type":"string", + "max":4096, + "min":1, + "sensitive":true + }, + "RelevantTextDocumentTextString":{ + "type":"string", + "max":40960, + "min":0, + "sensitive":true + }, + "Resource":{ + "type":"structure", + "required":[ + "title", + "link", + "description", + "type", + "ARN", + "resourceJsonString" ], - "members": { - "relativeFilePath": {"shape": "SensitiveString"}, - "programmingLanguage": {"shape": "ProgrammingLanguage"}, - "text": {"shape": "SensitiveString"}, - "documentSymbols": {"shape": "DocumentSymbols"} - } + "members":{ + "title":{ + "shape":"ResourceTitleString", + "documentation":"

Card title.

" + }, + "link":{ + "shape":"ResourceLinkString", + "documentation":"

Link for the resource item

" + }, + "description":{ + "shape":"ResourceDescriptionString", + "documentation":"

Short text about that resource for example Region: us-east-1

" + }, + "type":{ + "shape":"ResourceTypeString", + "documentation":"

Resource type e.g AWS EC2

" + }, + "ARN":{ + "shape":"ResourceARNString", + "documentation":"

Amazon resource number e.g arn:aws:aec:.....

" + }, + "resourceJsonString":{ + "shape":"ResourceResourceJsonStringString", + "documentation":"

A stringified object

" + } + }, + "documentation":"

Structure representing a resource item

" }, - "RelevantDocumentList": { - "type": "list", - "member": {"shape": "RelevantTextDocument"}, - "max": 5, - "min": 0 + "ResourceARNString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true }, "ResourceArn":{ "type":"string", @@ -513,14 +1515,67 @@ "min":0, "pattern":"arn:([-.a-z0-9]{1,63}:){2}([-.a-z0-9]{0,63}:){2}([a-zA-Z0-9-_:/]){1,1023}" }, + "ResourceDescriptionString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "ResourceLinkString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "ResourceList":{ + "type":"structure", + "required":["items"], + "members":{ + "action":{ + "shape":"Action", + "documentation":"

Action associated with the list

" + }, + "items":{ + "shape":"ResourceListItemsList", + "documentation":"

List of resources

" + } + }, + "documentation":"

Structure representing a list of Items

" + }, + "ResourceListItemsList":{ + "type":"list", + "member":{"shape":"Resource"}, + "documentation":"

List for resources

", + "max":10, + "min":0 + }, "ResourceNotFoundException":{ "type":"structure", "required":["message"], "members":{ "message":{"shape":"String"} }, + "documentation":"

This exception is thrown when describing a resource that does not exist.

", "exception":true }, + "ResourceResourceJsonStringString":{ + "type":"string", + "max":8192, + "min":0, + "sensitive":true + }, + "ResourceTitleString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "ResourceTypeString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, "ResultArchiveStream":{ "type":"structure", "members":{ @@ -528,6 +1583,7 @@ "binaryPayloadEvent":{"shape":"BinaryPayloadEvent"}, "internalServerException":{"shape":"InternalServerException"} }, + "documentation":"

Response Stream

", "eventstream":true }, "RuntimeDiagnostic":{ @@ -538,10 +1594,20 @@ "message" ], "members":{ - "source":{"shape":"RuntimeDiagnosticSourceString"}, - "severity":{"shape":"DiagnosticSeverity"}, - "message":{"shape":"RuntimeDiagnosticMessageString"} - } + "source":{ + "shape":"RuntimeDiagnosticSourceString", + "documentation":"

A human-readable string describing the source of the diagnostic

" + }, + "severity":{ + "shape":"DiagnosticSeverity", + "documentation":"

Diagnostic Error type

" + }, + "message":{ + "shape":"RuntimeDiagnosticMessageString", + "documentation":"

The diagnostic's message.

" + } + }, + "documentation":"

Structure to represent metadata about a Runtime Diagnostics

" }, "RuntimeDiagnosticMessageString":{ "type":"string", @@ -555,47 +1621,303 @@ "min":0, "sensitive":true }, - "SensitiveString":{ - "type":"string", - "sensitive":true + "Section":{ + "type":"structure", + "required":[ + "title", + "content" + ], + "members":{ + "title":{ + "shape":"SectionTitleString", + "documentation":"

Contains text content that may include sensitive information and can support Markdown formatting.

" + }, + "content":{ + "shape":"SectionContentList", + "documentation":"

Contains a list of interaction components e.g Text, Alert, List, etc.

" + }, + "action":{ + "shape":"Action", + "documentation":"

Action associated with the Section

" + } + }, + "documentation":"

Structure representing a collapsable section

" }, - "Span":{ + "SectionComponent":{ "type":"structure", "members":{ - "start":{"shape":"SpanStartInteger"}, - "end":{"shape":"SpanEndInteger"} + "text":{"shape":"Text"}, + "alert":{"shape":"Alert"}, + "resource":{"shape":"Resource"}, + "resourceList":{"shape":"ResourceList"} } }, - "SpanEndInteger":{ - "type":"integer", - "box":true, + "SectionContentList":{ + "type":"list", + "member":{"shape":"SectionComponent"}, + "max":5, "min":0 }, - "SpanStartInteger":{ - "type":"integer", - "box":true, - "min":0 + "SectionTitleString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true }, - "String":{"type":"string"}, - "SupplementaryWebLink":{ + "SendMessageRequest":{ "type":"structure", - "required":[ - "url", - "title" - ], + "required":["conversationState"], "members":{ - "url":{"shape":"SupplementaryWebLinkUrlString"}, - "title":{"shape":"SupplementaryWebLinkTitleString"}, - "snippet":{"shape":"SupplementaryWebLinkSnippetString"} - } + "conversationState":{"shape":"ConversationState"}, + "profileArn":{"shape":"ProfileArn"}, + "source":{ + "shape":"Origin", + "documentation":"

The origin of the caller

" + }, + "dryRun":{"shape":"Boolean"} + }, + "documentation":"

Structure to represent a SendMessage request.

" }, - "SupplementaryWebLinkSnippetString":{ - "type":"string", - "max":1024, - "min":1, + "SendMessageResponse":{ + "type":"structure", + "required":["sendMessageResponse"], + "members":{ + "sendMessageResponse":{"shape":"ChatResponseStream"} + }, + "documentation":"

Structure to represent a SendMessage response.

" + }, + "SensitiveDocument":{ + "type":"structure", + "members":{ + }, + "document":true, "sensitive":true }, - "SupplementaryWebLinkTitleString":{ + "SensitiveString":{ + "type":"string", + "sensitive":true + }, + "ServiceQuotaExceededException":{ + "type":"structure", + "required":["message"], + "members":{ + "message":{"shape":"String"} + }, + "documentation":"

This exception is thrown when request was denied due to caller exceeding their usage limits

", + "exception":true + }, + "ShellHistory":{ + "type":"list", + "member":{"shape":"ShellHistoryEntry"}, + "documentation":"

A list of shell history entries

", + "max":20, + "min":0 + }, + "ShellHistoryEntry":{ + "type":"structure", + "required":["command"], + "members":{ + "command":{ + "shape":"ShellHistoryEntryCommandString", + "documentation":"

The shell command that was run

" + }, + "directory":{ + "shape":"ShellHistoryEntryDirectoryString", + "documentation":"

The directory the command was ran in

" + }, + "exitCode":{ + "shape":"Integer", + "documentation":"

The exit code of the command after it finished

" + }, + "stdout":{ + "shape":"ShellHistoryEntryStdoutString", + "documentation":"

The stdout from the command

" + }, + "stderr":{ + "shape":"ShellHistoryEntryStderrString", + "documentation":"

The stderr from the command

" + } + }, + "documentation":"

An single entry in the shell history

" + }, + "ShellHistoryEntryCommandString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "ShellHistoryEntryDirectoryString":{ + "type":"string", + "max":256, + "min":1, + "sensitive":true + }, + "ShellHistoryEntryStderrString":{ + "type":"string", + "max":4096, + "min":0, + "sensitive":true + }, + "ShellHistoryEntryStdoutString":{ + "type":"string", + "max":4096, + "min":0, + "sensitive":true + }, + "ShellState":{ + "type":"structure", + "required":["shellName"], + "members":{ + "shellName":{ + "shape":"ShellStateShellNameString", + "documentation":"

The name of the current shell

" + }, + "shellHistory":{ + "shape":"ShellHistory", + "documentation":"

The history previous shell commands for the current shell

" + } + }, + "documentation":"

Represents the state of a shell

" + }, + "ShellStateShellNameString":{ + "type":"string", + "max":32, + "min":1, + "pattern":"(zsh|bash|fish|pwsh|nu)" + }, + "Span":{ + "type":"structure", + "members":{ + "start":{"shape":"SpanStartInteger"}, + "end":{"shape":"SpanEndInteger"} + }, + "documentation":"

Represents span in a text.

" + }, + "SpanEndInteger":{ + "type":"integer", + "box":true, + "min":0 + }, + "SpanStartInteger":{ + "type":"integer", + "box":true, + "min":0 + }, + "Step":{ + "type":"structure", + "required":[ + "id", + "state", + "label" + ], + "members":{ + "id":{ + "shape":"StepIdInteger", + "documentation":"

A unique identifier for the step. It must be a non-negative integer to ensure each step is distinct.

" + }, + "state":{"shape":"StepState"}, + "label":{ + "shape":"StepLabelString", + "documentation":"

A label for the step, providing a concise description.

" + }, + "content":{ + "shape":"StepComponentList", + "documentation":"

Optional content providing additional details about the step.

" + } + }, + "documentation":"

Structure representing an individual step in a process.

" + }, + "StepComponent":{ + "type":"structure", + "members":{ + "text":{"shape":"Text"} + } + }, + "StepComponentList":{ + "type":"list", + "member":{"shape":"StepComponent"} + }, + "StepIdInteger":{ + "type":"integer", + "box":true, + "max":128, + "min":0 + }, + "StepLabelString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "StepState":{ + "type":"string", + "documentation":"

Enum representing all possible step states, combining terminal and non-terminal states.

", + "enum":[ + "FAILED", + "SUCCEEDED", + "STOPPED", + "PENDING", + "IN_PROGRESS", + "LOADING", + "PAUSED" + ] + }, + "String":{"type":"string"}, + "Suggestion":{ + "type":"structure", + "required":["value"], + "members":{ + "value":{"shape":"SuggestionValueString"} + }, + "documentation":"

Structure representing a suggestion for follow-ups.

" + }, + "SuggestionList":{ + "type":"list", + "member":{"shape":"Suggestion"} + }, + "SuggestionValueString":{ + "type":"string", + "max":1000, + "min":1, + "sensitive":true + }, + "Suggestions":{ + "type":"structure", + "required":["items"], + "members":{ + "items":{"shape":"SuggestionList"} + }, + "documentation":"

Structure containing a list of suggestions.

" + }, + "SupplementaryWebLink":{ + "type":"structure", + "required":[ + "url", + "title" + ], + "members":{ + "url":{ + "shape":"SupplementaryWebLinkUrlString", + "documentation":"

URL of the web reference link.

" + }, + "title":{ + "shape":"SupplementaryWebLinkTitleString", + "documentation":"

Title of the web reference link.

" + }, + "snippet":{ + "shape":"SupplementaryWebLinkSnippetString", + "documentation":"

Relevant text snippet from the link.

" + } + }, + "documentation":"

Represents an additional reference link retured with the Chat message

" + }, + "SupplementaryWebLinkSnippetString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "SupplementaryWebLinkTitleString":{ "type":"string", "max":1024, "min":1, @@ -616,8 +1938,12 @@ "SupplementaryWebLinksEvent":{ "type":"structure", "members":{ - "supplementaryWebLinks":{"shape":"SupplementaryWebLinks"} + "supplementaryWebLinks":{ + "shape":"SupplementaryWebLinks", + "documentation":"

Web References for Assistant Response Message

" + } }, + "documentation":"

Streaming Response Event for SupplementaryWebLinks

", "event":true }, "SymbolType":{ @@ -627,21 +1953,233 @@ "USAGE" ] }, + "TaskAction":{ + "type":"structure", + "required":[ + "label", + "payload" + ], + "members":{ + "label":{ + "shape":"TaskActionLabelString", + "documentation":"

A label for the action.

" + }, + "note":{"shape":"TaskActionNote"}, + "primary":{ + "shape":"Boolean", + "documentation":"

Indicates whether the action is primary or not.

" + }, + "disabled":{ + "shape":"Boolean", + "documentation":"

Indicates whether the action is disabled or not.

" + }, + "payload":{"shape":"TaskActionPayload"}, + "confirmation":{"shape":"TaskActionConfirmation"} + }, + "documentation":"

Structure representing an action associated with a task.

" + }, + "TaskActionConfirmation":{ + "type":"structure", + "members":{ + "content":{ + "shape":"TaskActionConfirmationContentString", + "documentation":"

Confirmation message related to the action note, which may include sensitive information.

" + } + }, + "documentation":"

Structure representing a confirmation message related to a task action.

" + }, + "TaskActionConfirmationContentString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true + }, + "TaskActionLabelString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "TaskActionList":{ + "type":"list", + "member":{"shape":"TaskAction"} + }, + "TaskActionNote":{ + "type":"structure", + "required":["content"], + "members":{ + "content":{ + "shape":"TaskActionNoteContentString", + "documentation":"

Content of the note, which may include sensitive information.

" + }, + "type":{"shape":"TaskActionNoteType"} + }, + "documentation":"

Structure representing a note associated with a task action.

" + }, + "TaskActionNoteContentString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true + }, + "TaskActionNoteType":{ + "type":"string", + "documentation":"

Enum defining the types of notes that can be associated with a task action.

", + "enum":[ + "INFO", + "WARNING" + ] + }, + "TaskActionPayload":{ + "type":"map", + "key":{ + "shape":"TaskActionPayloadKeyString", + "documentation":"

The key for the payload entry.

" + }, + "value":{ + "shape":"TaskActionPayloadValueString", + "documentation":"

The sensitive value associated with the key.

" + }, + "documentation":"

Map representing key-value pairs for the payload of a task action.

", + "max":32, + "min":0 + }, + "TaskActionPayloadKeyString":{ + "type":"string", + "max":1024, + "min":1 + }, + "TaskActionPayloadValueString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "TaskComponent":{ + "type":"structure", + "members":{ + "text":{"shape":"Text"}, + "infrastructureUpdate":{"shape":"InfrastructureUpdate"}, + "alert":{"shape":"Alert"}, + "progress":{"shape":"Progress"} + }, + "documentation":"

Structure representing different types of components that can be part of a task.

" + }, + "TaskComponentList":{ + "type":"list", + "member":{"shape":"TaskComponent"} + }, + "TaskDetails":{ + "type":"structure", + "required":[ + "overview", + "content" + ], + "members":{ + "overview":{"shape":"TaskOverview"}, + "content":{ + "shape":"TaskComponentList", + "documentation":"

Lists the components that can be used to form the task's content.

" + }, + "actions":{ + "shape":"TaskActionList", + "documentation":"

Optional list of actions associated with the task.

" + } + }, + "documentation":"

Structure containing details about a task.

" + }, + "TaskOverview":{ + "type":"structure", + "required":[ + "label", + "description" + ], + "members":{ + "label":{ + "shape":"TaskOverviewLabelString", + "documentation":"

A label for the task overview.

" + }, + "description":{ + "shape":"TaskOverviewDescriptionString", + "documentation":"

Text description providing details about the task. This field may include sensitive information and supports Markdown formatting.

" + } + }, + "documentation":"

Structure representing an overview of a task, including a label and description.

" + }, + "TaskOverviewDescriptionString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true + }, + "TaskOverviewLabelString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "TaskReference":{ + "type":"structure", + "required":["taskId"], + "members":{ + "taskId":{ + "shape":"TaskReferenceTaskIdString", + "documentation":"

Unique identifier for the task.

" + } + }, + "documentation":"

Structure representing a reference to a task.

" + }, + "TaskReferenceTaskIdString":{ + "type":"string", + "max":128, + "min":1 + }, "TestGenerationJobGroupName":{ "type":"string", + "documentation":"

Test generation job group name

", "max":128, "min":1, "pattern":"[a-zA-Z0-9-_]+" }, + "Text":{ + "type":"structure", + "required":["content"], + "members":{ + "content":{ + "shape":"TextContentString", + "documentation":"

Contains text content that may include sensitive information and can support Markdown formatting.

" + } + }, + "documentation":"

Structure representing a simple text component with sensitive content, which can include Markdown formatting.

" + }, + "TextContentString":{ + "type":"string", + "max":10240, + "min":0, + "sensitive":true + }, "TextDocument":{ "type":"structure", "required":["relativeFilePath"], "members":{ - "relativeFilePath":{"shape":"TextDocumentRelativeFilePathString"}, - "programmingLanguage":{"shape":"ProgrammingLanguage"}, - "text":{"shape":"TextDocumentTextString"}, - "documentSymbols":{"shape":"DocumentSymbols"} - } + "relativeFilePath":{ + "shape":"TextDocumentRelativeFilePathString", + "documentation":"

Filepath relative to the root of the workspace

" + }, + "programmingLanguage":{ + "shape":"ProgrammingLanguage", + "documentation":"

The text document's language identifier.

" + }, + "text":{ + "shape":"TextDocumentTextString", + "documentation":"

Content of the text document

" + }, + "documentSymbols":{ + "shape":"DocumentSymbols", + "documentation":"

DocumentSymbols parsed from a text document

" + } + }, + "documentation":"

Represents a Text Document / File

" }, "TextDocumentDiagnostic":{ "type":"structure", @@ -653,12 +2191,54 @@ "message" ], "members":{ - "document":{"shape":"TextDocument"}, - "range":{"shape":"Range"}, - "source":{"shape":"SensitiveString"}, - "severity":{"shape":"DiagnosticSeverity"}, - "message":{"shape":"TextDocumentDiagnosticMessageString"} - } + "document":{ + "shape":"TextDocument", + "documentation":"

Represents a Text Document associated with Diagnostic

" + }, + "range":{ + "shape":"Range", + "documentation":"

The range at which the message applies.

" + }, + "source":{ + "shape":"SensitiveString", + "documentation":"

A human-readable string describing the source of the diagnostic

" + }, + "severity":{ + "shape":"DiagnosticSeverity", + "documentation":"

Diagnostic Error type

" + }, + "message":{ + "shape":"TextDocumentDiagnosticMessageString", + "documentation":"

The diagnostic's message.

" + }, + "code":{ + "shape":"Integer", + "documentation":"

The diagnostic's code, which might appear in the user interface.

" + }, + "codeDescription":{ + "shape":"CodeDescription", + "documentation":"

An optional property to describe the error code.

" + }, + "tags":{ + "shape":"DiagnosticTagList", + "documentation":"

Additional metadata about the diagnostic.

" + }, + "relatedInformation":{ + "shape":"DiagnosticRelatedInformationList", + "documentation":"

an array of related diagnostic information, e.g. when symbol-names within a scope collide all definitions can be marked via this property.

" + }, + "data":{ + "shape":"TextDocumentDiagnosticDataString", + "documentation":"

A data entry field that is preserved between a textDocument/publishDiagnostics notification and textDocument/codeAction request.

" + } + }, + "documentation":"

Structure to represent metadata about a TextDocument Diagnostic

" + }, + "TextDocumentDiagnosticDataString":{ + "type":"string", + "max":4096, + "min":0, + "sensitive":true }, "TextDocumentDiagnosticMessageString":{ "type":"string", @@ -674,7 +2254,7 @@ }, "TextDocumentTextString":{ "type":"string", - "max":10240, + "max":40000, "min":0, "sensitive":true }, @@ -682,25 +2262,189 @@ "type":"structure", "required":["message"], "members":{ - "message":{"shape":"String"} + "message":{"shape":"String"}, + "reason":{"shape":"ThrottlingExceptionReason"} }, + "documentation":"

This exception is thrown when request was denied due to request throttling.

", "exception":true, "retryable":{"throttling":true} }, - "ServiceQuotaExceededException":{ + "ThrottlingExceptionReason":{ + "type":"string", + "documentation":"

Reason for ThrottlingException

", + "enum":["MONTHLY_REQUEST_COUNT"] + }, + "Tool":{ "type":"structure", - "required":["message"], "members":{ - "message":{"shape":"String"} + "toolSpecification":{"shape":"ToolSpecification"} }, - "exception":true, - "retryable":{"throttling":true} + "documentation":"

Information about a tool that can be used.

", + "union":true + }, + "ToolDescription":{ + "type":"string", + "documentation":"

The description for the tool.

", + "max":10240, + "min":1, + "sensitive":true + }, + "ToolInputSchema":{ + "type":"structure", + "members":{ + "json":{"shape":"SensitiveDocument"} + }, + "documentation":"

The input schema for the tool in JSON format.

" + }, + "ToolName":{ + "type":"string", + "documentation":"

The name for the tool.

", + "max":64, + "min":0, + "pattern":"[a-zA-Z][a-zA-Z0-9_]*", + "sensitive":true + }, + "ToolResult":{ + "type":"structure", + "required":[ + "toolUseId", + "content" + ], + "members":{ + "toolUseId":{"shape":"ToolUseId"}, + "content":{ + "shape":"ToolResultContent", + "documentation":"

Content of the tool result.

" + }, + "status":{"shape":"ToolResultStatus"} + }, + "documentation":"

A tool result that contains the results for a tool request that was previously made.

" + }, + "ToolResultContent":{ + "type":"list", + "member":{"shape":"ToolResultContentBlock"} + }, + "ToolResultContentBlock":{ + "type":"structure", + "members":{ + "text":{ + "shape":"ToolResultContentBlockTextString", + "documentation":"

A tool result that is text.

" + }, + "json":{ + "shape":"SensitiveDocument", + "documentation":"

A tool result that is JSON format data.

" + } + }, + "union":true + }, + "ToolResultContentBlockTextString":{ + "type":"string", + "max":800000, + "min":0, + "sensitive":true + }, + "ToolResultEvent":{ + "type":"structure", + "members":{ + "toolResult":{"shape":"ToolResult"} + }, + "event":true + }, + "ToolResultStatus":{ + "type":"string", + "documentation":"

Status of the tools result.

", + "enum":[ + "success", + "error" + ] + }, + "ToolResults":{ + "type":"list", + "member":{"shape":"ToolResult"}, + "max":10, + "min":0 + }, + "ToolSpecification":{ + "type":"structure", + "required":[ + "inputSchema", + "name" + ], + "members":{ + "inputSchema":{"shape":"ToolInputSchema"}, + "name":{"shape":"ToolName"}, + "description":{"shape":"ToolDescription"} + }, + "documentation":"

The specification for the tool.

" + }, + "ToolUse":{ + "type":"structure", + "required":[ + "toolUseId", + "name", + "input" + ], + "members":{ + "toolUseId":{"shape":"ToolUseId"}, + "name":{"shape":"ToolName"}, + "input":{ + "shape":"SensitiveDocument", + "documentation":"

The input to pass to the tool.

" + } + }, + "documentation":"

Contains information about a tool that the model is requesting be run. The model uses the result from the tool to generate a response.

" + }, + "ToolUseEvent":{ + "type":"structure", + "required":[ + "toolUseId", + "name" + ], + "members":{ + "toolUseId":{"shape":"ToolUseId"}, + "name":{"shape":"ToolName"}, + "input":{ + "shape":"ToolUseEventInputString", + "documentation":"

Represents the serialized json input for the ToolUse request. This field should be concatenated until 'stop' is true.

" + }, + "stop":{ + "shape":"Boolean", + "documentation":"

This field is true when all of the serialized input for this ToolUse request has been sent.

" + } + }, + "documentation":"

Event for a ToolUse request. Multiple ToolUse requests can be returned from a single request, so each ToolUse has a unique 'toolUseId'.

", + "event":true + }, + "ToolUseEventInputString":{ + "type":"string", + "max":30720, + "min":0, + "sensitive":true + }, + "ToolUseId":{ + "type":"string", + "documentation":"

The ID for the tool request.

", + "max":64, + "min":0, + "pattern":"[a-zA-Z0-9_-]+" + }, + "ToolUses":{ + "type":"list", + "member":{"shape":"ToolUse"}, + "max":10, + "min":0 + }, + "Tools":{ + "type":"list", + "member":{"shape":"Tool"} }, "TransformationDownloadArtifactType":{ "type":"string", "enum":[ "ClientInstructions", - "Logs" + "Logs", + "GeneratedCode" ] }, "TransformationExportContext":{ @@ -712,7 +2456,8 @@ "members":{ "downloadArtifactId":{"shape":"ArtifactId"}, "downloadArtifactType":{"shape":"TransformationDownloadArtifactType"} - } + }, + "documentation":"

Transformation export context

" }, "UUID":{ "type":"string", @@ -725,10 +2470,12 @@ "members":{ "testGenerationJobGroupName":{"shape":"TestGenerationJobGroupName"}, "testGenerationJobId":{"shape":"UUID"} - } + }, + "documentation":"

Unit test generation export context

" }, "UploadId":{ "type":"string", + "documentation":"

Upload ID returned by CreateUploadUrl API

", "max":128, "min":1 }, @@ -736,26 +2483,88 @@ "type":"structure", "required":["content"], "members":{ - "content":{"shape":"UserInputMessageContentString"}, - "userInputMessageContext":{"shape":"UserInputMessageContext"}, - "userIntent":{"shape":"UserIntent"} - } + "content":{ + "shape":"UserInputMessageContentString", + "documentation":"

The content of the chat message.

" + }, + "userInputMessageContext":{ + "shape":"UserInputMessageContext", + "documentation":"

Chat message context associated with the Chat Message.

" + }, + "userIntent":{ + "shape":"UserIntent", + "documentation":"

User Intent.

" + }, + "origin":{ + "shape":"Origin", + "documentation":"

User Input Origin.

" + }, + "images":{ + "shape":"ImageBlocks", + "documentation":"

Images associated with the Chat Message.

" + } + }, + "documentation":"

Structure to represent a chat input message from User.

" }, "UserInputMessageContentString":{ "type":"string", - "max":4096, + "max":600000, "min":0, "sensitive":true }, "UserInputMessageContext":{ "type":"structure", "members":{ - "editorState":{"shape":"EditorState"}, - "diagnostic":{"shape":"Diagnostic"} - } + "editorState":{ + "shape":"EditorState", + "documentation":"

Editor state chat message context.

" + }, + "shellState":{ + "shape":"ShellState", + "documentation":"

Shell state chat message context.

" + }, + "gitState":{ + "shape":"GitState", + "documentation":"

Git state chat message context.

" + }, + "envState":{ + "shape":"EnvState", + "documentation":"

Environment state chat message context.

" + }, + "appStudioContext":{ + "shape":"AppStudioState", + "documentation":"

The state of a user's AppStudio UI when sending a message.

" + }, + "diagnostic":{ + "shape":"Diagnostic", + "documentation":"

Diagnostic chat message context.

" + }, + "consoleState":{ + "shape":"ConsoleState", + "documentation":"

Contextual information about the environment from which the user is calling.

" + }, + "userSettings":{ + "shape":"UserSettings", + "documentation":"

Settings information, e.g., whether the user has enabled cross-region API calls.

" + }, + "additionalContext":{ + "shape":"AdditionalContentList", + "documentation":"

List of additional contextual content entries that can be included with the message.

" + }, + "toolResults":{ + "shape":"ToolResults", + "documentation":"

ToolResults for the requested ToolUses.

" + }, + "tools":{ + "shape":"Tools", + "documentation":"

Tools that can be used.

" + } + }, + "documentation":"

Additional Chat message context associated with the Chat Message

" }, "UserIntent":{ "type":"string", + "documentation":"

User Intent

", "enum":[ "SUGGEST_ALTERNATE_IMPLEMENTATION", "APPLY_COMMON_BEST_PRACTICES", @@ -764,9 +2573,18 @@ "CITE_SOURCES", "EXPLAIN_LINE_BY_LINE", "EXPLAIN_CODE_SELECTION", - "GENERATE_UNIT_TESTS" + "GENERATE_CLOUDFORMATION_TEMPLATE", + "GENERATE_UNIT_TESTS", + "CODE_GENERATION" ] }, + "UserSettings":{ + "type":"structure", + "members":{ + "hasConsentedToCrossRegionCalls":{"shape":"Boolean"} + }, + "documentation":"

Settings information passed by the Q widget

" + }, "ValidationException":{ "type":"structure", "required":["message"], @@ -774,11 +2592,58 @@ "message":{"shape":"String"}, "reason":{"shape":"ValidationExceptionReason"} }, + "documentation":"

This exception is thrown when the input fails to satisfy the constraints specified by the service.

", "exception":true }, "ValidationExceptionReason":{ "type":"string", - "enum":["INVALID_CONVERSATION_ID"] + "documentation":"

Reason for ValidationException

", + "enum":[ + "INVALID_CONVERSATION_ID", + "CONTENT_LENGTH_EXCEEDS_THRESHOLD", + "INVALID_KMS_GRANT" + ] + }, + "WebLink":{ + "type":"structure", + "required":[ + "label", + "url" + ], + "members":{ + "label":{ + "shape":"WebLinkLabelString", + "documentation":"

A label for the link

" + }, + "url":{ + "shape":"WebLinkUrlString", + "documentation":"

URL of the Weblink

" + } + } + }, + "WebLinkLabelString":{ + "type":"string", + "max":1024, + "min":0, + "sensitive":true + }, + "WebLinkUrlString":{ + "type":"string", + "max":1024, + "min":1, + "sensitive":true + }, + "WorkspaceFolderList":{ + "type":"list", + "member":{"shape":"WorkspaceFolderListMemberString"}, + "max":100, + "min":0 + }, + "WorkspaceFolderListMemberString":{ + "type":"string", + "max":4096, + "min":1, + "sensitive":true }, "WorkspaceState":{ "type":"structure", @@ -787,10 +2652,20 @@ "programmingLanguage" ], "members":{ - "uploadId":{"shape":"UploadId"}, - "programmingLanguage":{"shape":"ProgrammingLanguage"}, - "contextTruncationScheme":{"shape":"ContextTruncationScheme"} - } + "uploadId":{ + "shape":"UploadId", + "documentation":"

Upload ID representing an Upload using a PreSigned URL

" + }, + "programmingLanguage":{ + "shape":"ProgrammingLanguage", + "documentation":"

Primary programming language of the Workspace

" + }, + "contextTruncationScheme":{ + "shape":"ContextTruncationScheme", + "documentation":"

Workspace context truncation schemes based on usecase

" + } + }, + "documentation":"

Represents a Workspace state uploaded to S3 for Async Code Actions

" } } } diff --git a/plugins/core/webview/src/ideClient.ts b/plugins/core/webview/src/ideClient.ts index 0abed7d8527..b427682eb0d 100644 --- a/plugins/core/webview/src/ideClient.ts +++ b/plugins/core/webview/src/ideClient.ts @@ -2,22 +2,62 @@ // SPDX-License-Identifier: Apache-2.0 import {Store} from "vuex"; -import {IdcInfo, Region, Stage, State, BrowserSetupData, AwsBearerTokenConnection} from "./model"; +import { + IdcInfo, + State, + AuthSetupMessageFromIde, + ListProfileResult, + ListProfileSuccessResult, + ListProfileFailureResult, ListProfilePendingResult, ListProfilesMessageFromIde +} from "./model"; +import {WebviewTelemetry} from './webviewTelemetry' export class IdeClient { constructor(private readonly store: Store) {} // TODO: design and improve the API here - prepareUi(state: BrowserSetupData) { + prepareUi(state: AuthSetupMessageFromIde | ListProfilesMessageFromIde) { + WebviewTelemetry.instance.reset() console.log('browser is preparing UI with state ', state) + // hack as window.onerror don't have access to vuex store + void ((window as any).uiState = state.stage) + WebviewTelemetry.instance.willShowPage(state.stage) + this.store.commit('setStage', state.stage) - this.store.commit('setSsoRegions', state.regions) - this.updateLastLoginIdcInfo(state.idcInfo) - this.store.commit("setCancellable", state.cancellable) - this.store.commit("setFeature", state.feature) - const existConnections = state.existConnections.map(it => { + switch (state.stage) { + case "PROFILE_SELECT": + this.handleProfileSelectMessage(state as ListProfilesMessageFromIde) + break + + default: + this.handleAuthSetupMessage(state as AuthSetupMessageFromIde) + } + } + + private handleProfileSelectMessage(msg: ListProfilesMessageFromIde) { + let result: ListProfileResult | undefined + switch (msg.status) { + case 'succeeded': + result = new ListProfileSuccessResult(msg.profiles) + break + case 'failed': + result = new ListProfileFailureResult(msg.errorMessage) + break + case 'pending': + result = new ListProfilePendingResult() + break + } + this.store.commit('setProfilesResult', result) + } + + private handleAuthSetupMessage(msg: AuthSetupMessageFromIde) { + this.store.commit('setSsoRegions', msg.regions) + this.updateLastLoginIdcInfo(msg.idcInfo) + this.store.commit("setCancellable", msg.cancellable) + this.store.commit("setFeature", msg.feature) + const existConnections = msg.existConnections.map(it => { return { sessionName: it.sessionName, startUrl: it.startUrl, diff --git a/plugins/core/webview/src/model.ts b/plugins/core/webview/src/model.ts index 424628e011b..8ace3779882 100644 --- a/plugins/core/webview/src/model.ts +++ b/plugins/core/webview/src/model.ts @@ -1,15 +1,23 @@ // Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export type BrowserSetupData = { +export type AuthSetupMessageFromIde = { stage: Stage, regions: Region[], idcInfo: IdcInfo, cancellable: boolean, feature: string, - existConnections: AwsBearerTokenConnection[] + existConnections: AwsBearerTokenConnection[], } +export type ListProfilesMessageFromIde = { + stage: Stage, + status: 'succeeded' | 'failed' | 'pending', + profiles: Profile[], + errorMessage: string +} + + // plugin interface [AwsBearerTokenConnection] export interface AwsBearerTokenConnection { sessionName: string, @@ -18,6 +26,7 @@ export interface AwsBearerTokenConnection { scopes: string[], id: string } + export const SONO_URL = "https://view.awsapps.com/start" export type Stage = @@ -26,7 +35,8 @@ export type Stage = 'CONNECTED' | 'AUTHENTICATING' | 'AWS_PROFILE' | - 'REAUTH' + 'REAUTH' | + 'PROFILE_SELECT' export type Feature = 'Q' | 'codecatalyst' | 'awsExplorer' @@ -50,7 +60,28 @@ export interface State { lastLoginIdcInfo: IdcInfo, feature: Feature, cancellable: boolean, - existingConnections: AwsBearerTokenConnection[] + existingConnections: AwsBearerTokenConnection[], + listProfilesResult: ListProfileResult | undefined, + selectedProfile: Profile | undefined +} + +export interface ListProfileResult { + status: 'succeeded' | 'failed' | 'pending' +} + +export class ListProfileSuccessResult implements ListProfileResult { + status: 'succeeded' = 'succeeded' + constructor(readonly profiles: Profile[]) {} +} + +export class ListProfileFailureResult implements ListProfileResult { + status: 'failed' = 'failed' + constructor(readonly errorMessage: string) {} +} + +export class ListProfilePendingResult implements ListProfileResult { + status: 'pending' = 'pending' + constructor() {} } export enum LoginIdentifier { @@ -67,6 +98,15 @@ export interface LoginOption { requiresBrowser(): boolean } +export interface Profile { + profileName: string + accountId: string + region: string + arn: String +} + +export const GENERIC_PROFILE_LOAD_ERROR = "We couldn't load your Q Developer profiles. Please try again."; + export class LongLivedIAM implements LoginOption { id: LoginIdentifier = LoginIdentifier.IAM_CREDENTIAL diff --git a/plugins/core/webview/src/q-ui/components/login.vue b/plugins/core/webview/src/q-ui/components/login.vue index 260c44e6e95..6b31e1efe4d 100644 --- a/plugins/core/webview/src/q-ui/components/login.vue +++ b/plugins/core/webview/src/q-ui/components/login.vue @@ -4,8 +4,7 @@