Skip to content

Commit 7cceffe

Browse files
Merge master into feature/q-dev-execution
2 parents b392e7b + 41f64ae commit 7cceffe

File tree

18 files changed

+1156
-7
lines changed

18 files changed

+1156
-7
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.generic.dismiss%",
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,7 @@
55
"AWS.productName.cn": "Amazon Toolkit",
66
"AWS.amazonq.productName": "Amazon Q",
77
"AWS.codecatalyst.submenu.title": "Manage CodeCatalyst",
8+
"AWS.notifications.title": "Notifications",
89
"AWS.configuration.profileDescription": "The name of the credential profile to obtain credentials from.",
910
"AWS.configuration.description.lambda.recentlyUploaded": "Recently selected Lambda upload targets.",
1011
"AWS.configuration.description.ecs.openTerminalCommand": "The command to run when starting a new interactive terminal session.",
@@ -245,6 +246,7 @@
245246
"AWS.generic.promptUpdate": "Update...",
246247
"AWS.generic.preview": "Preview",
247248
"AWS.generic.viewDocs": "View Documentation",
249+
"AWS.generic.dismiss": "Dismiss",
248250
"AWS.ssmDocument.ssm.maxItemsComputed.desc": "Controls the maximum number of problems produced by the SSM Document language server.",
249251
"AWS.walkthrough.gettingStarted.title": "Get started with AWS",
250252
"AWS.walkthrough.gettingStarted.description": "These walkthroughs help you set up the AWS Toolkit.",

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: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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, NotificationsStateConstructor, NotificationType, 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+
import { isAmazonQ } from '../shared/extensionUtilities'
20+
21+
/**
22+
* Handles fetching and maintaining the state of in-IDE notifications.
23+
* Notifications are constantly polled from a known endpoint and then stored in global state.
24+
* The global state is used to compare if there are a change in notifications on the endpoint
25+
* or if the endpoint is not reachable.
26+
*
27+
* This class will send any notifications to {@link NotificationsNode} for display.
28+
* Notifications can be dismissed.
29+
*
30+
* Startup notifications - fetched each start up.
31+
* Emergency notifications - fetched at a regular interval.
32+
*/
33+
export class NotificationsController {
34+
public static readonly suggestedPollIntervalMs = 1000 * 60 * 10 // 10 minutes
35+
36+
public readonly storageKey: globalKey
37+
38+
/** Internal memory state that is written to global state upon modification. */
39+
private readonly state: NotificationsState
40+
41+
static #instance: NotificationsController | undefined
42+
43+
constructor(
44+
private readonly notificationsNode: NotificationsNode,
45+
private readonly fetcher: NotificationFetcher = new RemoteFetcher()
46+
) {
47+
if (!NotificationsController.#instance) {
48+
// Register on first creation only.
49+
registerDismissCommand()
50+
}
51+
NotificationsController.#instance = this
52+
53+
this.storageKey = 'aws.notifications'
54+
this.state = globals.globalState.tryGet<NotificationsState>(this.storageKey, NotificationsStateConstructor, {
55+
startUp: {},
56+
emergency: {},
57+
dismissed: [],
58+
})
59+
}
60+
61+
public pollForStartUp(ruleEngine: RuleEngine) {
62+
return this.poll(ruleEngine, 'startUp')
63+
}
64+
65+
public pollForEmergencies(ruleEngine: RuleEngine) {
66+
return this.poll(ruleEngine, 'emergency')
67+
}
68+
69+
private async poll(ruleEngine: RuleEngine, category: NotificationType) {
70+
try {
71+
await this.fetchNotifications(category)
72+
} catch (err: any) {
73+
getLogger('notifications').error(`Unable to fetch %s notifications: %s`, category, err)
74+
}
75+
76+
await this.displayNotifications(ruleEngine)
77+
}
78+
79+
private async displayNotifications(ruleEngine: RuleEngine) {
80+
const dismissed = new Set(this.state.dismissed)
81+
const startUp =
82+
this.state.startUp.payload?.notifications.filter(
83+
(n) => !dismissed.has(n.id) && ruleEngine.shouldDisplayNotification(n)
84+
) ?? []
85+
const emergency = (this.state.emergency.payload?.notifications ?? []).filter((n) =>
86+
ruleEngine.shouldDisplayNotification(n)
87+
)
88+
89+
NotificationsNode.instance.setNotifications(startUp, emergency)
90+
91+
// Emergency notifications can't be dismissed, but if the user minimizes the panel then
92+
// we don't want to focus it each time we set the notification nodes.
93+
// So we store it in dismissed once a focus has been fired for it.
94+
const newEmergencies = emergency.map((n) => n.id).filter((id) => !dismissed.has(id))
95+
if (newEmergencies.length > 0) {
96+
this.state.dismissed = [...this.state.dismissed, ...newEmergencies]
97+
await this.writeState()
98+
void this.notificationsNode.focusPanel()
99+
}
100+
}
101+
102+
/**
103+
* Permanently hides a notification from view. Only 'startUp' notifications can be dismissed.
104+
* Users are able to collapse or hide the notifications panel in native VSC if they want to
105+
* hide all notifications.
106+
*/
107+
public async dismissNotification(notificationId: string) {
108+
getLogger('notifications').debug('Dismissing notification: %s', notificationId)
109+
this.state.dismissed.push(notificationId)
110+
await this.writeState()
111+
112+
NotificationsNode.instance.dismissStartUpNotification(notificationId)
113+
}
114+
115+
/**
116+
* Fetch notifications from the endpoint and store them in the global state.
117+
*/
118+
private async fetchNotifications(category: NotificationType) {
119+
const response = await this.fetcher.fetch(category, this.state[category].eTag)
120+
if (!response.content) {
121+
getLogger('notifications').verbose('No new notifications for category: %s', category)
122+
return
123+
}
124+
125+
getLogger('notifications').verbose('ETAG has changed for notifications category: %s', category)
126+
127+
this.state[category].payload = JSON.parse(response.content)
128+
this.state[category].eTag = response.eTag
129+
await this.writeState()
130+
131+
getLogger('notifications').verbose(
132+
"Fetched notifications JSON for category '%s' with schema version: %s. There were %d notifications.",
133+
category,
134+
this.state[category].payload?.schemaVersion,
135+
this.state[category].payload?.notifications?.length
136+
)
137+
}
138+
139+
/**
140+
* Write the latest memory state to global state.
141+
*/
142+
private async writeState() {
143+
getLogger('notifications').debug('NotificationsController: Updating notifications state at %s', this.storageKey)
144+
145+
// Clean out anything in 'dismissed' that doesn't exist anymore.
146+
const notifications = new Set(
147+
[
148+
...(this.state.startUp.payload?.notifications ?? []),
149+
...(this.state.emergency.payload?.notifications ?? []),
150+
].map((n) => n.id)
151+
)
152+
this.state.dismissed = this.state.dismissed.filter((id) => notifications.has(id))
153+
154+
await globals.globalState.update(this.storageKey, this.state)
155+
}
156+
157+
static get instance() {
158+
if (this.#instance === undefined) {
159+
throw new ToolkitError('NotificationsController was accessed before it has been initialized.')
160+
}
161+
162+
return this.#instance
163+
}
164+
}
165+
166+
function registerDismissCommand() {
167+
const name = isAmazonQ() ? '_aws.amazonq.notifications.dismiss' : '_aws.toolkit.notifications.dismiss'
168+
169+
globals.context.subscriptions.push(
170+
Commands.register(name, async (node: TreeNode) => {
171+
const item = node?.getTreeItem()
172+
if (item instanceof vscode.TreeItem && item.command?.arguments) {
173+
// The command used to build the TreeNode contains the notification as an argument.
174+
/** See {@link NotificationsNode} for more info. */
175+
const notification = item.command?.arguments[0] as ToolkitNotification
176+
177+
await NotificationsController.instance.dismissNotification(notification.id)
178+
} else {
179+
getLogger('notifications').error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`)
180+
}
181+
})
182+
)
183+
}
184+
185+
export type ResourceResponse = Awaited<ReturnType<HttpResourceFetcher['getNewETagContent']>>
186+
187+
export interface NotificationFetcher {
188+
/**
189+
* Fetch notifications from some source. If there is no (new) data to fetch, then the response's
190+
* content value will be undefined.
191+
*
192+
* @param type typeof NotificationType
193+
* @param versionTag last known version of the data aka ETAG. Can be used to determine if the data changed.
194+
*/
195+
fetch(type: NotificationType, versionTag?: string): Promise<ResourceResponse>
196+
}
197+
198+
export class RemoteFetcher implements NotificationFetcher {
199+
public static readonly retryNumber = 5
200+
public static readonly retryIntervalMs = 30000
201+
202+
private readonly startUpEndpoint: string =
203+
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/startup/1.x.json'
204+
private readonly emergencyEndpoint: string =
205+
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/emergency/1.x.json'
206+
207+
constructor(startUpPath?: string, emergencyPath?: string) {
208+
this.startUpEndpoint = startUpPath ?? this.startUpEndpoint
209+
this.emergencyEndpoint = emergencyPath ?? this.emergencyEndpoint
210+
}
211+
212+
fetch(category: NotificationType, versionTag?: string): Promise<ResourceResponse> {
213+
const endpoint = category === 'startUp' ? this.startUpEndpoint : this.emergencyEndpoint
214+
const fetcher = new HttpResourceFetcher(endpoint, {
215+
showUrl: true,
216+
})
217+
getLogger('notifications').verbose(
218+
'Attempting to fetch notifications for category: %s at endpoint: %s',
219+
category,
220+
endpoint
221+
)
222+
223+
return withRetries(async () => await fetcher.getNewETagContent(versionTag), {
224+
maxRetries: RemoteFetcher.retryNumber,
225+
delay: RemoteFetcher.retryIntervalMs,
226+
// No exponential backoff - necessary?
227+
})
228+
}
229+
}
230+
231+
/**
232+
* Can be used when developing locally. This may be expanded at some point to allow notifications
233+
* to be published via github rather than internally.
234+
*
235+
* versionTag (ETAG) is ignored.
236+
*/
237+
export class LocalFetcher implements NotificationFetcher {
238+
// Paths relative to running extension root folder (e.g. packages/amazonq/).
239+
private readonly startUpLocalPath: string = '../core/src/test/notifications/resources/startup/1.x.json'
240+
private readonly emergencyLocalPath: string = '../core/src/test/notifications/resources/emergency/1.x.json'
241+
242+
constructor(startUpPath?: string, emergencyPath?: string) {
243+
this.startUpLocalPath = startUpPath ?? this.startUpLocalPath
244+
this.emergencyLocalPath = emergencyPath ?? this.emergencyLocalPath
245+
}
246+
247+
async fetch(category: NotificationType, versionTag?: string): Promise<ResourceResponse> {
248+
const uri = category === 'startUp' ? this.startUpLocalPath : this.emergencyLocalPath
249+
getLogger('notifications').verbose(
250+
'Attempting to fetch notifications locally for category: %s at path: %s',
251+
category,
252+
uri
253+
)
254+
255+
return {
256+
content: await new FileResourceFetcher(globals.context.asAbsolutePath(uri)).get(),
257+
eTag: 'LOCAL_PATH',
258+
}
259+
}
260+
}
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)