Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.notifications.dismiss.title%",
"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,8 @@
"AWS.productName.cn": "Amazon Toolkit",
"AWS.amazonq.productName": "Amazon Q",
"AWS.codecatalyst.submenu.title": "Manage CodeCatalyst",
"AWS.notifications.title": "Notifications",
"AWS.notifications.dismiss.title": "Dismiss",
"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
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
233 changes: 233 additions & 0 deletions packages/core/src/notifications/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/*!
* 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, NotificationType, NotificationData, 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'

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

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

/**
* 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 retryNumber = 5
public static readonly retryIntervalMs = 30000
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
private readonly notificationsNode: NotificationsNode

static #instance: NotificationsController | undefined

constructor(extPrefix: 'amazonq' | 'toolkit', node: NotificationsNode) {
if (!NotificationsController.#instance) {
registerDismissCommand(extPrefix)
}
NotificationsController.#instance = this

this.storageKey = `aws.${extPrefix}.notifications`
this.notificationsNode = node

this.state = globals.globalState.get(this.storageKey) ?? {
startUp: {} as NotificationData,
emergency: {} as NotificationData,
dismissed: [],
}
this.state.startUp = this.state.startUp ?? {}
this.state.emergency = this.state.emergency ?? {}
this.state.dismissed = this.state.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().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().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 = _useLocalFiles ? await this.fetchLocally(category) : await this.fetchRemotely(category)
if (!response.content) {
getLogger().verbose('No new notifications for category: %s', category)
return
}

getLogger().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().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
)
}

private fetchRemotely(category: NotificationType): Promise<ResourceResponse> {
const fetcher = new HttpResourceFetcher(category === 'startUp' ? startUpEndpoint : emergencyEndpoint, {
showUrl: true,
})

return withRetries(async () => await fetcher.getNewETagContent(this.state[category].eTag), {
maxRetries: NotificationsController.retryNumber,
delay: NotificationsController.retryIntervalMs,
// No exponential backoff - necessary?
})
}

/**
* Fetch notifications from local files.
* Intended development purposes only. In the future, we may support adding notifications
* directly to the codebase.
*/
private async fetchLocally(category: NotificationType): Promise<ResourceResponse> {
if (!_useLocalFiles) {
throw new ToolkitError('fetchLocally: Local file fetching is not enabled.')
}

const uri = category === 'startUp' ? startUpLocalPath : emergencyLocalPath
const content = await new FileResourceFetcher(globals.context.asAbsolutePath(uri)).get()

getLogger().verbose('Fetched notifications locally for category: %s at path: %s', category, uri)
return {
content,
eTag: 'LOCAL_PATH',
}
}

/**
* Write the latest memory state to global state.
*/
private async writeState() {
getLogger().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(extPrefix: string) {
const name = `_aws.${extPrefix}.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().error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`)
}
})
)
}

/**
* For development purposes only.
* Enable this option to test the notifications system locally.
*/
const _useLocalFiles = false
export const _useLocalFilesCheck = _useLocalFiles // export for testing

const startUpLocalPath = '../core/src/test/notifications/resources/startup/1.x.json'
const emergencyLocalPath = '../core/src/test/notifications/resources/emergency/1.x.json'
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