Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -244,6 +245,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 @@
/*!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

somewhat off-topic, but would it avoid boilerplate if the main/top-level core index.ts file instead just exports all notifications stuff under a notifications item? that has at least these advantages:

  • avoids needing to update package.json
  • avoids needing to create this file
  • centralizes the index
  • avoids consumers having to hunt for index files (they import one "mega index")

Copy link
Contributor Author

@hayemaxi hayemaxi Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to a top-level index.js, you would still need a lower level one to collect the individual files though right?
e.g.

src/notifications/index.ts

export * from 'controller.ts'
export * from 'types.ts'

src/index.ts

export * as notifications from 'notifications/index'

Maybe you could do this, but this looks like it would be very messy for many exports:

src/index.ts

import * as controller from './src/notifications/controller';
import * as types from './src/notifications/types';

export const notifications = {
  ...controller,
  ...types,
}

Also, not sure how autocomplete will behave. Needs investigation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you would still need a lower level one to collect the individual files though right?
e.g.

no, could do that manually in the top level index.ts like this:

export * as messages from './utilities/messages'
export * as errors from './errors'

of course, this means the top level has to "choose" some names, but in practice that doesn't seem to be a problem, and it may actually lead to a better interface for the consumers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So then you have pretty granular exports anyways, right? e.g.

export * as notificationsTypes from './src/notifications/types'
export * as notificationsRules from './src/notifications/rules'
export * as notificationsController from './src/notifications/controller'

which isn't as intuitive as a single notifications export from all of these places.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, though it depends on what consumers actually need. Generally we shouldn't need to expose most modules in the "public" interface.

Tracked in https://taskei.amazon.dev/tasks/IDE-15174

* 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
Loading