From a6debbf106f36d2bc5a0510ce19fea40c85c993f Mon Sep 17 00:00:00 2001 From: Will Lo Date: Wed, 9 Apr 2025 23:42:06 -0700 Subject: [PATCH 1/5] profile loading ux --- .../services/amazonq/QLoginWebview.kt | 127 +++++++------ .../jetbrains/core/webview/BrowserMessage.kt | 2 +- plugins/core/webview/src/ideClient.ts | 64 +++++-- plugins/core/webview/src/model.ts | 42 ++++- .../src/q-ui/components/profileSelection.vue | 167 +++++++++++------- plugins/core/webview/src/q-ui/index.ts | 17 +- plugins/core/webview/src/q-ui/toolkit.ts | 3 +- 7 files changed, 265 insertions(+), 157 deletions(-) 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 bb56a5e06d1..36d43d7de07 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 @@ -182,16 +182,16 @@ class QWebviewBrowser(val project: Project, private val parentDisposable: Dispos ToolkitConnectionManager.getInstance(project) .activeConnectionForFeature(QConnection.getInstance()) as? AwsBearerTokenConnection )?.let { connection -> - runInEdt { - SsoLogoutAction(connection).actionPerformed( - AnActionEvent.createFromDataContext( - "qBrowser", - null, - DataContext.EMPTY_CONTEXT + runInEdt { + SsoLogoutAction(connection).actionPerformed( + AnActionEvent.createFromDataContext( + "qBrowser", + null, + DataContext.EMPTY_CONTEXT + ) ) - ) + } } - } } is BrowserMessage.Reauth -> { @@ -261,56 +261,79 @@ class QWebviewBrowser(val project: Project, private val parentDisposable: Dispos writeValueAsString(it) } - // TODO: pass "REAUTH" if connection expires - // Perform the potentially blocking AWS call outside the EDT to fetch available region profiles. - ApplicationManager.getApplication().executeOnPooledThread { - val stage = if (isQExpired(project)) { - "REAUTH" - } else if (isQConnected(project) && QRegionProfileManager.getInstance().isPendingProfileSelection(project)) { - "PROFILE_SELECT" - } else { - "START" - } + val stage = if (isQExpired(project)) { + "REAUTH" + } else if (isQConnected(project) && QRegionProfileManager.getInstance().isPendingProfileSelection(project)) { + "PROFILE_SELECT" + } else { + "START" + } + + when (stage) { + "PROFILE_SELECT" -> { + ApplicationManager.getApplication().executeOnPooledThread { + var errorMessage: String = "" + 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) + } - var errorMessage: String? = null - var profiles: List = emptyList() - - if (stage == "PROFILE_SELECT") { - try { - profiles = QRegionProfileManager.getInstance().listRegionProfiles(project).orEmpty() - } catch (e: Exception) { - errorMessage = e.message - LOG.warn { "Failed to call listRegionProfiles API" } - 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 + } + + // required EDT as this entire block is executed on thread pool + runInEdt { + val jsonData = """ + { + stage: '$stage', + status: '${if (profiles != null) "succeeded" else "failed"}', + profiles: ${writeValueAsString(profiles ?: "")}, + errorMessage: '$errorMessage' + } + """.trimIndent() + + println(jsonData) + executeJS("window.ideClient.prepareUi($jsonData)") } } - } - 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())}, - profiles: ${writeValueAsString(profiles)}, - errorMessage: ${errorMessage?.let { "\"$it\"" } ?: "null"} + val jsonData = """ + { + stage: '$stage', + status: 'pending' + } + """.trimIndent() + executeJS("window.ideClient.prepareUi($jsonData)") } - """.trimIndent() - runInEdt { + 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)") } } 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 32f0a0e1576..98957a6e90d 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 @@ -29,8 +29,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo JsonSubTypes.Type(value = BrowserMessage.SwitchProfile::class, name = "switchProfile"), JsonSubTypes.Type(value = BrowserMessage.PublishWebviewTelemetry::class, name = "webviewTelemetry") ) +// Webview to IDE sealed interface BrowserMessage { - // FIX_WHEN_MIN_IS_233: data objects are not stable until Kotlin 1.9 // https://kotlinlang.org/docs/whatsnew19.html#stable-data-objects-for-symmetry-with-data-classes object PrepareUi : BrowserMessage diff --git a/plugins/core/webview/src/ideClient.ts b/plugins/core/webview/src/ideClient.ts index 902e854d33f..82eb0bf2835 100644 --- a/plugins/core/webview/src/ideClient.ts +++ b/plugins/core/webview/src/ideClient.ts @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import {Store} from "vuex"; -import {IdcInfo, Region, Stage, State, BrowserSetupData, AwsBearerTokenConnection, Profile} from "./model"; +import { + IdcInfo, + State, + AuthSetupMessageFromIde, + ListProfileResult, + ListProfileSuccessResult, + ListProfileFailureResult, ListProfilePendingResult, ListProfilesMessageFromIde +} from "./model"; import {WebviewTelemetry} from './webviewTelemetry' export class IdeClient { @@ -10,20 +17,48 @@ export class IdeClient { // TODO: design and improve the API here - prepareUi(state: BrowserSetupData) { + prepareUi(state: any) { WebviewTelemetry.instance.reset() console.log('browser is preparing UI with state ', state) - this.store.commit('setStage', state.stage) // 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('setSsoRegions', state.regions) - this.updateLastLoginIdcInfo(state.idcInfo) - this.store.commit("setCancellable", state.cancellable) - this.store.commit("setFeature", state.feature) - this.store.commit('setProfiles', state.profiles); - this.store.commit("setErrorMessage", state.errorMessage) - const existConnections = state.existConnections.map(it => { + + this.store.commit('setStage', state.stage) + + switch (state.stage) { + case "PROFILE_SELECT": + this.handleProfileSelectMessage(state) + break + + default: + this.handleAuthSetupMessage(state) + } + } + + 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 + } + console.log(result) + 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, @@ -37,13 +72,6 @@ export class IdeClient { this.updateAuthorization(undefined) } - handleProfiles(profilesData: { profiles: Profile[] }) { - this.store.commit('setStage', 'PROFILE_SELECT') - console.debug("received profile data") - const availableProfiles: Profile[] = profilesData.profiles; - this.store.commit('setProfiles', availableProfiles); - } - updateAuthorization(code: string | undefined) { this.store.commit('setAuthorizationCode', code) // TODO: mutage stage to AUTHing here probably makes life easier @@ -60,6 +88,6 @@ export class IdeClient { cancelLogin(): void { // this.reset() this.store.commit('setStage', 'START') - window.ideApi.postMessage({ command: 'cancelLogin' }) + window.ideApi.postMessage({command: 'cancelLogin'}) } } diff --git a/plugins/core/webview/src/model.ts b/plugins/core/webview/src/model.ts index d30f7f85eb7..6318f9d82bb 100644 --- a/plugins/core/webview/src/model.ts +++ b/plugins/core/webview/src/model.ts @@ -1,17 +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[], +} + +export type ListProfilesMessageFromIde = { + stage: Stage, + status: 'succeeded' | 'failed' | 'pending', profiles: Profile[], errorMessage: string } + // plugin interface [AwsBearerTokenConnection] export interface AwsBearerTokenConnection { sessionName: string, @@ -20,6 +26,7 @@ export interface AwsBearerTokenConnection { scopes: string[], id: string } + export const SONO_URL = "https://view.awsapps.com/start" export type Stage = @@ -54,9 +61,33 @@ export interface State { feature: Feature, cancellable: boolean, existingConnections: AwsBearerTokenConnection[], - profiles: Profile[], - selectedProfile: Profile | undefined, - errorMessage: string | undefined + 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 { @@ -115,7 +146,8 @@ export class BuilderId implements LoginOption { export class ExistConnection implements LoginOption { id: LoginIdentifier = LoginIdentifier.EXISTING_LOGINS - constructor(readonly pluginConnectionId: string) {} + constructor(readonly pluginConnectionId: string) { + } // this case only happens for bearer connection for now requiresBrowser(): boolean { diff --git a/plugins/core/webview/src/q-ui/components/profileSelection.vue b/plugins/core/webview/src/q-ui/components/profileSelection.vue index d9f4719974d..0b62513bcd3 100644 --- a/plugins/core/webview/src/q-ui/components/profileSelection.vue +++ b/plugins/core/webview/src/q-ui/components/profileSelection.vue @@ -3,95 +3,117 @@