Skip to content

Commit 736901c

Browse files
committed
feat(notifications): notifications controller and view panel
- NotificationsController - Fetches notifications and determines which ones to display. - NotificationsNode - Side panel that displays a given list (from controller) of notifications. The controller will fetch notifications from an endpoint (or local files during development). These notifications will be stored in memory and global state. They will then be evaluated for dismissal/criteria and if they pass they will be send to the NotificationsNode for display.
1 parent c44fa30 commit 736901c

File tree

16 files changed

+1027
-6
lines changed

16 files changed

+1027
-6
lines changed

packages/amazonq/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@
188188
},
189189
"views": {
190190
"amazonq": [
191+
{
192+
"id": "aws.amazonq.notifications",
193+
"name": "%AWS.notifications.title%",
194+
"when": "!isCloud9 && !aws.isSageMaker && aws.amazonq.notifications.show"
195+
},
191196
{
192197
"type": "webview",
193198
"id": "aws.amazonq.AmazonCommonAuth",
@@ -370,6 +375,13 @@
370375
"group": "cw_chat"
371376
}
372377
],
378+
"view/item/context": [
379+
{
380+
"command": "_aws.amazonq.notifications.dismiss",
381+
"when": "viewItem == amazonqNotificationStartUp",
382+
"group": "inline@1"
383+
}
384+
],
373385
"aws.amazonq.submenu.feedback": [
374386
{
375387
"command": "aws.amazonq.submitFeedback",
@@ -397,6 +409,13 @@
397409
]
398410
},
399411
"commands": [
412+
{
413+
"command": "_aws.amazonq.notifications.dismiss",
414+
"title": "%AWS.notifications.dismiss.title%",
415+
"category": "%AWS.amazonq.title%",
416+
"enablement": "isCloud9 || !aws.isWebExtHost",
417+
"icon": "$(remove-close)"
418+
},
400419
{
401420
"command": "aws.amazonq.explainCode",
402421
"title": "%AWS.command.amazonq.explainCode%",

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"./utils": "./dist/src/shared/utilities/index.js",
2828
"./feedback": "./dist/src/feedback/index.js",
2929
"./telemetry": "./dist/src/shared/telemetry/index.js",
30-
"./dev": "./dist/src/dev/index.js"
30+
"./dev": "./dist/src/dev/index.js",
31+
"./notifications": "./dist/src/notifications/index.js"
3132
},
3233
"contributes": {
3334
"icons": {

packages/core/package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"AWS.productName.cn": "Amazon Toolkit",
66
"AWS.amazonq.productName": "Amazon Q",
77
"AWS.codecatalyst.submenu.title": "Manage CodeCatalyst",
8+
"AWS.notifications.title": "Notifications",
9+
"AWS.notifications.dismiss.title": "Dismiss",
810
"AWS.configuration.profileDescription": "The name of the credential profile to obtain credentials from.",
911
"AWS.configuration.description.lambda.recentlyUploaded": "Recently selected Lambda upload targets.",
1012
"AWS.configuration.description.ecs.openTerminalCommand": "The command to run when starting a new interactive terminal session.",

packages/core/src/codewhisperer/util/zipUtil.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { tempDirPath } from '../../shared/filesystemUtilities'
99
import { getLogger } from '../../shared/logger'
1010
import * as CodeWhispererConstants from '../models/constants'
1111
import { ToolkitError } from '../../shared/errors'
12-
import { fs } from '../../shared'
12+
import { fs } from '../../shared/fs/fs'
1313
import { getLoggerForScope } from '../service/securityScanHandler'
1414
import { runtimeLanguageContext } from './runtimeLanguageContext'
1515
import { CodewhispererLanguage } from '../../shared/telemetry/telemetry.gen'
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import { ToolkitError } from '../shared/errors'
8+
import globals from '../shared/extensionGlobals'
9+
import { globalKey } from '../shared/globalState'
10+
import { NotificationsState, NotificationType, CategoryState, ToolkitNotification } from './types'
11+
import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher'
12+
import { getLogger } from '../shared/logger/logger'
13+
import { NotificationsNode } from './panelNode'
14+
import { Commands } from '../shared/vscode/commands2'
15+
import { RuleEngine } from './rules'
16+
import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
17+
import { withRetries } from '../shared/utilities/functionUtils'
18+
import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher'
19+
20+
const startUpEndpoint = 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/startup/1.x.json'
21+
const emergencyEndpoint = 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/emergency/1.x.json'
22+
23+
type ResourceResponse = Awaited<ReturnType<HttpResourceFetcher['getNewETagContent']>>
24+
25+
/**
26+
* Handles fetching and maintaining the state of in-IDE notifications.
27+
* Notifications are constantly polled from a known endpoint and then stored in global state.
28+
* The global state is used to compare if there are a change in notifications on the endpoint
29+
* or if the endpoint is not reachable.
30+
*
31+
* This class will send any notifications to {@link NotificationsNode} for display.
32+
* Notifications can be dismissed.
33+
*
34+
* Startup notifications - fetched each start up.
35+
* Emergency notifications - fetched at a regular interval.
36+
*/
37+
export class NotificationsController {
38+
public static readonly retryNumber = 5
39+
public static readonly retryIntervalMs = 30000
40+
public static readonly suggestedPollIntervalMs = 1000 * 60 * 10 // 10 minutes
41+
42+
public readonly storageKey: globalKey
43+
44+
/** Internal memory state that is written to global state upon modification. */
45+
private readonly state: NotificationsState
46+
private readonly notificationsNode: NotificationsNode
47+
48+
static #instance: NotificationsController | undefined
49+
50+
constructor(extPrefix: 'amazonq' | 'toolkit', node: NotificationsNode) {
51+
if (!NotificationsController.#instance) {
52+
registerDismissCommand(extPrefix)
53+
}
54+
NotificationsController.#instance = this
55+
56+
this.storageKey = `aws.${extPrefix}.notifications`
57+
this.notificationsNode = node
58+
59+
this.state = globals.globalState.get(this.storageKey) ?? {
60+
startUp: {} as CategoryState,
61+
emergency: {} as CategoryState,
62+
dismissed: [],
63+
}
64+
this.state.startUp = this.state.startUp ?? {}
65+
this.state.emergency = this.state.emergency ?? {}
66+
this.state.dismissed = this.state.dismissed ?? []
67+
}
68+
69+
public pollForStartUp(ruleEngine: RuleEngine) {
70+
return this.poll(ruleEngine, 'startUp')
71+
}
72+
73+
public pollForEmergencies(ruleEngine: RuleEngine) {
74+
return this.poll(ruleEngine, 'emergency')
75+
}
76+
77+
private async poll(ruleEngine: RuleEngine, category: NotificationType) {
78+
try {
79+
await this.fetchNotifications(category)
80+
} catch (err: any) {
81+
getLogger().error(`Unable to fetch %s notifications: %s`, category, err)
82+
}
83+
84+
await this.displayNotifications(ruleEngine)
85+
}
86+
87+
private async displayNotifications(ruleEngine: RuleEngine) {
88+
const dismissed = new Set(this.state.dismissed)
89+
const startUp =
90+
this.state.startUp.payload?.notifications.filter(
91+
(n) => !dismissed.has(n.id) && ruleEngine.shouldDisplayNotification(n)
92+
) ?? []
93+
const emergency = (this.state.emergency.payload?.notifications ?? []).filter((n) =>
94+
ruleEngine.shouldDisplayNotification(n)
95+
)
96+
97+
NotificationsNode.instance.setNotifications(startUp, emergency)
98+
99+
// Emergency notifications can't be dismissed, but if the user minimizes the panel then
100+
// we don't want to focus it each time we set the notification nodes.
101+
// So we store it in dismissed once a focus has been fired for it.
102+
const newEmergencies = emergency.map((n) => n.id).filter((id) => !dismissed.has(id))
103+
if (newEmergencies.length > 0) {
104+
this.state.dismissed = [...this.state.dismissed, ...newEmergencies]
105+
await this.writeState()
106+
void this.notificationsNode.focusPanel()
107+
}
108+
}
109+
110+
/**
111+
* Permanently hides a notification from view. Only 'startUp' notifications can be dismissed.
112+
* Users are able to collapse or hide the notifications panel in native VSC if they want to
113+
* hide all notifications.
114+
*/
115+
public async dismissNotification(notificationId: string) {
116+
getLogger().debug('Dismissing notification: %s', notificationId)
117+
this.state.dismissed.push(notificationId)
118+
await this.writeState()
119+
120+
NotificationsNode.instance.dismissStartUpNotification(notificationId)
121+
}
122+
123+
/**
124+
* Fetch notifications from the endpoint and store them in the global state.
125+
*/
126+
private async fetchNotifications(category: NotificationType) {
127+
const response = _useLocalFiles ? await this.fetchLocally(category) : await this.fetchRemotely(category)
128+
if (!response.content) {
129+
getLogger().verbose('No new notifications for category: %s', category)
130+
return
131+
}
132+
133+
getLogger().verbose('ETAG has changed for notifications category: %s', category)
134+
135+
this.state[category].payload = JSON.parse(response.content)
136+
this.state[category].etag = response.eTag
137+
await this.writeState()
138+
139+
getLogger().verbose(
140+
"Fetched notifications JSON for category '%s' with schema version: %s. There were %d notifications.",
141+
category,
142+
this.state[category].payload?.schemaVersion,
143+
this.state[category].payload?.notifications?.length
144+
)
145+
}
146+
147+
private fetchRemotely(category: NotificationType): Promise<ResourceResponse> {
148+
const fetcher = new HttpResourceFetcher(category === 'startUp' ? startUpEndpoint : emergencyEndpoint, {
149+
showUrl: true,
150+
})
151+
152+
return withRetries(async () => await fetcher.getNewETagContent(this.state[category].etag), {
153+
maxRetries: NotificationsController.retryNumber,
154+
delay: NotificationsController.retryIntervalMs,
155+
// No exponential backoff - necessary?
156+
})
157+
}
158+
159+
/**
160+
* Fetch notifications from local files.
161+
* Intended development purposes only. In the future, we may support adding notifications
162+
* directly to the codebase.
163+
*/
164+
private async fetchLocally(category: NotificationType): Promise<ResourceResponse> {
165+
const uri = category === 'startUp' ? startUpLocalPath : emergencyLocalPath
166+
const content = await new FileResourceFetcher(globals.context.asAbsolutePath(uri)).get()
167+
168+
getLogger().verbose('Fetched notifications locally for category: %s at path: %s', category, uri)
169+
return {
170+
content,
171+
eTag: 'LOCAL_PATH',
172+
}
173+
}
174+
175+
/**
176+
* Write the latest memory state to global state.
177+
*/
178+
private async writeState() {
179+
getLogger().debug('NotificationsController: Updating notifications state at %s', this.storageKey)
180+
181+
// Clean out anything in 'dismissed' that doesn't exist anymore.
182+
const notifications = new Set(
183+
[
184+
...(this.state.startUp.payload?.notifications ?? []),
185+
...(this.state.emergency.payload?.notifications ?? []),
186+
].map((n) => n.id)
187+
)
188+
this.state.dismissed = this.state.dismissed.filter((id) => notifications.has(id))
189+
190+
await globals.globalState.update(this.storageKey, this.state)
191+
}
192+
193+
static get instance() {
194+
if (this.#instance === undefined) {
195+
throw new ToolkitError('NotificationsController was accessed before it has been initialized.')
196+
}
197+
198+
return this.#instance
199+
}
200+
}
201+
202+
function registerDismissCommand(extPrefix: string) {
203+
globals.context.subscriptions.push(
204+
Commands.register(`_aws.${extPrefix}.notifications.dismiss`, async (node: TreeNode) => {
205+
const item = node?.getTreeItem()
206+
if (item instanceof vscode.TreeItem && item.command?.arguments) {
207+
// The command used to build the TreeNode contains the notification as an argument.
208+
/** See {@link NotificationsNode} for more info. */
209+
const notification = item.command?.arguments[0] as ToolkitNotification
210+
211+
await NotificationsController.instance.dismissNotification(notification.id)
212+
} else {
213+
getLogger().debug('Cannot dismiss notification: item is not a vscode.TreeItem')
214+
}
215+
})
216+
)
217+
}
218+
219+
/**
220+
* For development purposes only.
221+
* Enable this option to test the notifications system locally.
222+
*/
223+
const _useLocalFiles = false
224+
export const _useLocalFilesCheck = _useLocalFiles // export for testing
225+
226+
const startUpLocalPath = '../core/src/test/notifications/resources/startup/1.x.json'
227+
const emergencyLocalPath = '../core/src/test/notifications/resources/emergency/1.x.json'
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export { RuleContext } from './types'
7+
export { NotificationsController } from './controller'
8+
export { RuleEngine } from './rules'
9+
export { registerProvider, NotificationsNode } from './panelNode'

0 commit comments

Comments
 (0)