Skip to content

Commit

Permalink
Merge master into feature/q-dev-execution
Browse files Browse the repository at this point in the history
  • Loading branch information
aws-toolkit-automation authored Oct 25, 2024
2 parents b392e7b + 41f64ae commit 7cceffe
Show file tree
Hide file tree
Showing 18 changed files with 1,156 additions and 7 deletions.
19 changes: 19 additions & 0 deletions packages/amazonq/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@
},
"views": {
"amazonq": [
{
"id": "aws.amazonq.notifications",
"name": "%AWS.notifications.title%",
"when": "!isCloud9 && !aws.isSageMaker && aws.amazonq.notifications.show"
},
{
"type": "webview",
"id": "aws.amazonq.AmazonCommonAuth",
Expand Down Expand Up @@ -370,6 +375,13 @@
"group": "cw_chat"
}
],
"view/item/context": [
{
"command": "_aws.amazonq.notifications.dismiss",
"when": "viewItem == amazonqNotificationStartUp",
"group": "inline@1"
}
],
"aws.amazonq.submenu.feedback": [
{
"command": "aws.amazonq.submitFeedback",
Expand Down Expand Up @@ -397,6 +409,13 @@
]
},
"commands": [
{
"command": "_aws.amazonq.notifications.dismiss",
"title": "%AWS.generic.dismiss%",
"category": "%AWS.amazonq.title%",
"enablement": "isCloud9 || !aws.isWebExtHost",
"icon": "$(remove-close)"
},
{
"command": "aws.amazonq.explainCode",
"title": "%AWS.command.amazonq.explainCode%",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"./utils": "./dist/src/shared/utilities/index.js",
"./feedback": "./dist/src/feedback/index.js",
"./telemetry": "./dist/src/shared/telemetry/index.js",
"./dev": "./dist/src/dev/index.js"
"./dev": "./dist/src/dev/index.js",
"./notifications": "./dist/src/notifications/index.js"
},
"contributes": {
"icons": {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"AWS.productName.cn": "Amazon Toolkit",
"AWS.amazonq.productName": "Amazon Q",
"AWS.codecatalyst.submenu.title": "Manage CodeCatalyst",
"AWS.notifications.title": "Notifications",
"AWS.configuration.profileDescription": "The name of the credential profile to obtain credentials from.",
"AWS.configuration.description.lambda.recentlyUploaded": "Recently selected Lambda upload targets.",
"AWS.configuration.description.ecs.openTerminalCommand": "The command to run when starting a new interactive terminal session.",
Expand Down Expand Up @@ -245,6 +246,7 @@
"AWS.generic.promptUpdate": "Update...",
"AWS.generic.preview": "Preview",
"AWS.generic.viewDocs": "View Documentation",
"AWS.generic.dismiss": "Dismiss",
"AWS.ssmDocument.ssm.maxItemsComputed.desc": "Controls the maximum number of problems produced by the SSM Document language server.",
"AWS.walkthrough.gettingStarted.title": "Get started with AWS",
"AWS.walkthrough.gettingStarted.description": "These walkthroughs help you set up the AWS Toolkit.",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/codewhisperer/util/zipUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { tempDirPath } from '../../shared/filesystemUtilities'
import { getLogger } from '../../shared/logger'
import * as CodeWhispererConstants from '../models/constants'
import { ToolkitError } from '../../shared/errors'
import { fs } from '../../shared'
import { fs } from '../../shared/fs/fs'
import { getLoggerForScope } from '../service/securityScanHandler'
import { runtimeLanguageContext } from './runtimeLanguageContext'
import { CodewhispererLanguage } from '../../shared/telemetry/telemetry.gen'
Expand Down
260 changes: 260 additions & 0 deletions packages/core/src/notifications/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import * as vscode from 'vscode'
import { ToolkitError } from '../shared/errors'
import globals from '../shared/extensionGlobals'
import { globalKey } from '../shared/globalState'
import { NotificationsState, NotificationsStateConstructor, NotificationType, ToolkitNotification } from './types'
import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher'
import { getLogger } from '../shared/logger/logger'
import { NotificationsNode } from './panelNode'
import { Commands } from '../shared/vscode/commands2'
import { RuleEngine } from './rules'
import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
import { withRetries } from '../shared/utilities/functionUtils'
import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher'
import { isAmazonQ } from '../shared/extensionUtilities'

/**
* Handles fetching and maintaining the state of in-IDE notifications.
* Notifications are constantly polled from a known endpoint and then stored in global state.
* The global state is used to compare if there are a change in notifications on the endpoint
* or if the endpoint is not reachable.
*
* This class will send any notifications to {@link NotificationsNode} for display.
* Notifications can be dismissed.
*
* Startup notifications - fetched each start up.
* Emergency notifications - fetched at a regular interval.
*/
export class NotificationsController {
public static readonly suggestedPollIntervalMs = 1000 * 60 * 10 // 10 minutes

public readonly storageKey: globalKey

/** Internal memory state that is written to global state upon modification. */
private readonly state: NotificationsState

static #instance: NotificationsController | undefined

constructor(
private readonly notificationsNode: NotificationsNode,
private readonly fetcher: NotificationFetcher = new RemoteFetcher()
) {
if (!NotificationsController.#instance) {
// Register on first creation only.
registerDismissCommand()
}
NotificationsController.#instance = this

this.storageKey = 'aws.notifications'
this.state = globals.globalState.tryGet<NotificationsState>(this.storageKey, NotificationsStateConstructor, {
startUp: {},
emergency: {},
dismissed: [],
})
}

public pollForStartUp(ruleEngine: RuleEngine) {
return this.poll(ruleEngine, 'startUp')
}

public pollForEmergencies(ruleEngine: RuleEngine) {
return this.poll(ruleEngine, 'emergency')
}

private async poll(ruleEngine: RuleEngine, category: NotificationType) {
try {
await this.fetchNotifications(category)
} catch (err: any) {
getLogger('notifications').error(`Unable to fetch %s notifications: %s`, category, err)
}

await this.displayNotifications(ruleEngine)
}

private async displayNotifications(ruleEngine: RuleEngine) {
const dismissed = new Set(this.state.dismissed)
const startUp =
this.state.startUp.payload?.notifications.filter(
(n) => !dismissed.has(n.id) && ruleEngine.shouldDisplayNotification(n)
) ?? []
const emergency = (this.state.emergency.payload?.notifications ?? []).filter((n) =>
ruleEngine.shouldDisplayNotification(n)
)

NotificationsNode.instance.setNotifications(startUp, emergency)

// Emergency notifications can't be dismissed, but if the user minimizes the panel then
// we don't want to focus it each time we set the notification nodes.
// So we store it in dismissed once a focus has been fired for it.
const newEmergencies = emergency.map((n) => n.id).filter((id) => !dismissed.has(id))
if (newEmergencies.length > 0) {
this.state.dismissed = [...this.state.dismissed, ...newEmergencies]
await this.writeState()
void this.notificationsNode.focusPanel()
}
}

/**
* Permanently hides a notification from view. Only 'startUp' notifications can be dismissed.
* Users are able to collapse or hide the notifications panel in native VSC if they want to
* hide all notifications.
*/
public async dismissNotification(notificationId: string) {
getLogger('notifications').debug('Dismissing notification: %s', notificationId)
this.state.dismissed.push(notificationId)
await this.writeState()

NotificationsNode.instance.dismissStartUpNotification(notificationId)
}

/**
* Fetch notifications from the endpoint and store them in the global state.
*/
private async fetchNotifications(category: NotificationType) {
const response = await this.fetcher.fetch(category, this.state[category].eTag)
if (!response.content) {
getLogger('notifications').verbose('No new notifications for category: %s', category)
return
}

getLogger('notifications').verbose('ETAG has changed for notifications category: %s', category)

this.state[category].payload = JSON.parse(response.content)
this.state[category].eTag = response.eTag
await this.writeState()

getLogger('notifications').verbose(
"Fetched notifications JSON for category '%s' with schema version: %s. There were %d notifications.",
category,
this.state[category].payload?.schemaVersion,
this.state[category].payload?.notifications?.length
)
}

/**
* Write the latest memory state to global state.
*/
private async writeState() {
getLogger('notifications').debug('NotificationsController: Updating notifications state at %s', this.storageKey)

// Clean out anything in 'dismissed' that doesn't exist anymore.
const notifications = new Set(
[
...(this.state.startUp.payload?.notifications ?? []),
...(this.state.emergency.payload?.notifications ?? []),
].map((n) => n.id)
)
this.state.dismissed = this.state.dismissed.filter((id) => notifications.has(id))

await globals.globalState.update(this.storageKey, this.state)
}

static get instance() {
if (this.#instance === undefined) {
throw new ToolkitError('NotificationsController was accessed before it has been initialized.')
}

return this.#instance
}
}

function registerDismissCommand() {
const name = isAmazonQ() ? '_aws.amazonq.notifications.dismiss' : '_aws.toolkit.notifications.dismiss'

globals.context.subscriptions.push(
Commands.register(name, async (node: TreeNode) => {
const item = node?.getTreeItem()
if (item instanceof vscode.TreeItem && item.command?.arguments) {
// The command used to build the TreeNode contains the notification as an argument.
/** See {@link NotificationsNode} for more info. */
const notification = item.command?.arguments[0] as ToolkitNotification

await NotificationsController.instance.dismissNotification(notification.id)
} else {
getLogger('notifications').error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`)
}
})
)
}

export type ResourceResponse = Awaited<ReturnType<HttpResourceFetcher['getNewETagContent']>>

export interface NotificationFetcher {
/**
* Fetch notifications from some source. If there is no (new) data to fetch, then the response's
* content value will be undefined.
*
* @param type typeof NotificationType
* @param versionTag last known version of the data aka ETAG. Can be used to determine if the data changed.
*/
fetch(type: NotificationType, versionTag?: string): Promise<ResourceResponse>
}

export class RemoteFetcher implements NotificationFetcher {
public static readonly retryNumber = 5
public static readonly retryIntervalMs = 30000

private readonly startUpEndpoint: string =
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/startup/1.x.json'
private readonly emergencyEndpoint: string =
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/emergency/1.x.json'

constructor(startUpPath?: string, emergencyPath?: string) {
this.startUpEndpoint = startUpPath ?? this.startUpEndpoint
this.emergencyEndpoint = emergencyPath ?? this.emergencyEndpoint
}

fetch(category: NotificationType, versionTag?: string): Promise<ResourceResponse> {
const endpoint = category === 'startUp' ? this.startUpEndpoint : this.emergencyEndpoint
const fetcher = new HttpResourceFetcher(endpoint, {
showUrl: true,
})
getLogger('notifications').verbose(
'Attempting to fetch notifications for category: %s at endpoint: %s',
category,
endpoint
)

return withRetries(async () => await fetcher.getNewETagContent(versionTag), {
maxRetries: RemoteFetcher.retryNumber,
delay: RemoteFetcher.retryIntervalMs,
// No exponential backoff - necessary?
})
}
}

/**
* Can be used when developing locally. This may be expanded at some point to allow notifications
* to be published via github rather than internally.
*
* versionTag (ETAG) is ignored.
*/
export class LocalFetcher implements NotificationFetcher {
// Paths relative to running extension root folder (e.g. packages/amazonq/).
private readonly startUpLocalPath: string = '../core/src/test/notifications/resources/startup/1.x.json'
private readonly emergencyLocalPath: string = '../core/src/test/notifications/resources/emergency/1.x.json'

constructor(startUpPath?: string, emergencyPath?: string) {
this.startUpLocalPath = startUpPath ?? this.startUpLocalPath
this.emergencyLocalPath = emergencyPath ?? this.emergencyLocalPath
}

async fetch(category: NotificationType, versionTag?: string): Promise<ResourceResponse> {
const uri = category === 'startUp' ? this.startUpLocalPath : this.emergencyLocalPath
getLogger('notifications').verbose(
'Attempting to fetch notifications locally for category: %s at path: %s',
category,
uri
)

return {
content: await new FileResourceFetcher(globals.context.asAbsolutePath(uri)).get(),
eTag: 'LOCAL_PATH',
}
}
}
9 changes: 9 additions & 0 deletions packages/core/src/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

export { RuleContext } from './types'
export { NotificationsController } from './controller'
export { RuleEngine } from './rules'
export { registerProvider, NotificationsNode } from './panelNode'
Loading

0 comments on commit 7cceffe

Please sign in to comment.