From e71d6fddedd3bd79abec0c8178558e310860a4ac Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 5 Jan 2024 16:20:48 -0800 Subject: [PATCH 1/9] Add startup notificaitons defined by remote GH source --- .metadata/README.md | 39 ++++++++ .metadata/notifications.json | 19 ++++ .../dependency-installer.ts | 10 +- extension/src/extension.ts | 36 ++++--- extension/src/main.ts | 5 - extension/src/storage/storage-provider.ts | 21 ++++ extension/src/ui/notifications.ts | 96 +++++++++++++++++++ extension/src/ui/prompts.ts | 35 +++++-- .../test/integration/2 - commands.test.ts | 21 +++- foo.cdc | 0 10 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 .metadata/README.md create mode 100644 .metadata/notifications.json create mode 100644 extension/src/storage/storage-provider.ts create mode 100644 extension/src/ui/notifications.ts delete mode 100644 foo.cdc diff --git a/.metadata/README.md b/.metadata/README.md new file mode 100644 index 00000000..224fd760 --- /dev/null +++ b/.metadata/README.md @@ -0,0 +1,39 @@ +# Extension metadata + +**DO NOT DELETE THIS FOLDER UNLESS YOU KNOW WHAT YOU ARE DOING** + +This folder contains remotely-updated metadata to provide updates to the Cadence VSCode Extension without requiring a new release of the extension itself. When consuming this metadata, the latest commit to the default repository branch should be assumed to be the latest version of the extension metadata. + +Currently, it is only used by the Cadence VSCode Extension to fetch any notifications that should be displayed to the user. + +## Notfications schema + +```ts +interface Notification { + _type: 'Notification' + id: string + type: 'error' | 'info' | 'warning' + text: string + buttons?: Array<{ + label: string + link: string + }> + suppressable?: boolean + compatibility?: { + 'vscode-cadence'?: string + 'flow-cli'?: string + } +} +``` + +### Fields + +- `_type`: The type of the object. Should always be `"Notification"`. +- `id`: A unique identifier for the notification. This is used to determine if the notification has already been displayed to the user. +- `type`: The type of notification. Can be `"info"`, `"warning"`, or `"error"`. +- `text`: The text to display to the user. +- `buttons`: An array of buttons to display to the user. Each button should have a `text` field and a `link` field. The `link` field should be a URL to open when the button is clicked. +- `suppressable`: Whether or not the user should be able to suppress the notification. (defaults to `true`) +- `compatibility`: An object containing compatibility information for the notification. If all of the specified compatibility requirements are met, the notification will be displayed to the user. If not, the notification will be ignored. The following compatibility requirements are supported: + - `vscode-cadence`: The version of the Cadence VSCode Extension that the user must be running. Can be a specific version number (e.g. `"0.0.1"`) or a semver range (e.g. `"^0.0.1"`). + - `flow-cli`: The version of the Flow CLI that the user must be running. Can be a specific version number (e.g. `"0.25.0"`) or a semver range (e.g. `"^0.25.0"`). \ No newline at end of file diff --git a/.metadata/notifications.json b/.metadata/notifications.json new file mode 100644 index 00000000..9217ecf6 --- /dev/null +++ b/.metadata/notifications.json @@ -0,0 +1,19 @@ +[ + { + "_type": "Notification", + "id": "1", + "type": "info", + "text": "Cadence 1.0 pre-release builds are now available! Developers should begin upgrading their projects - see the Cadence 1.0 Upgrade Plan for more details.", + "buttons": [ + { + "text": "Learn More", + "link": "https://forum.flow.com/t/cadence-1-0-upgrade-plan/5477#what-does-it-mean-for-me-if-i-am-a-2" + } + ], + "suppressable": true, + "versions": { + "vscode-cadence": "*", + "flow-cli": "*" + } + } +] \ No newline at end of file diff --git a/extension/src/dependency-installer/dependency-installer.ts b/extension/src/dependency-installer/dependency-installer.ts index 418de0e5..baab1265 100644 --- a/extension/src/dependency-installer/dependency-installer.ts +++ b/extension/src/dependency-installer/dependency-installer.ts @@ -38,8 +38,12 @@ export class DependencyInstaller { // Prompt user to install missing dependencies promptUserErrorMessage( 'Not all dependencies are installed: ' + missing.map(x => x.getName()).join(', '), - 'Install Missing Dependencies', - () => { void this.#installMissingDependencies() } + [ + { + label: 'Install Missing Dependencies', + callback: () => { void this.#installMissingDependencies() } + } + ] ) } }) @@ -74,7 +78,7 @@ export class DependencyInstaller { const missing = await this.missingDependencies.getValue() const installed: Installer[] = this.registeredInstallers.filter(x => !missing.includes(x)) - await new Promise((resolve, reject) => { + await new Promise((resolve) => { setTimeout(() => { resolve() }, 2000) }) diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 94a46526..ece47bba 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -1,4 +1,5 @@ -/* The extension */ +import './crypto-polyfill' + import { CommandController } from './commands/command-controller' import { ExtensionContext } from 'vscode' import { DependencyInstaller } from './dependency-installer/dependency-installer' @@ -8,9 +9,9 @@ import { flowVersion } from './utils/flow-version' import { LanguageServerAPI } from './server/language-server' import { FlowConfig } from './server/flow-config' import { TestProvider } from './test-provider/test-provider' +import { StorageProvider } from './storage/storage-provider' import * as path from 'path' - -import './crypto-polyfill' +import { Notification, displayNotifications, fetchNotifications, filterNotifications } from './ui/notifications' // The container for all data relevant to the extension. export class Extension { @@ -18,21 +19,36 @@ export class Extension { static #instance: Extension static initialized = false - static initialize (settings: Settings, ctx?: ExtensionContext): Extension { + static initialize (settings: Settings, ctx: ExtensionContext): Extension { Extension.#instance = new Extension(settings, ctx) Extension.initialized = true return Extension.#instance } - ctx: ExtensionContext | undefined + ctx: ExtensionContext languageServer: LanguageServerAPI #dependencyInstaller: DependencyInstaller #commands: CommandController - #testProvider?: TestProvider + #testProvider: TestProvider - private constructor (settings: Settings, ctx: ExtensionContext | undefined) { + private constructor (settings: Settings, ctx: ExtensionContext) { this.ctx = ctx + // Initialize Storage Provider + const storageProvider = new StorageProvider(ctx?.globalState) + + // Display any notifications from remote server + flowVersion.getValue().then(flowVersion => { + if (flowVersion == null) return + const notificationFilter = (notifications: Notification[]) => filterNotifications(notifications, storageProvider, { + 'vscode-cadence': this.ctx.extension.packageJSON.version ?? '0.0.0', + 'flow-cli': flowVersion.version + }) + fetchNotifications(notificationFilter).then(notifications => { + displayNotifications(notifications, storageProvider) + }) + }) + // Register JSON schema provider if (ctx != null) JSONSchemaProvider.register(ctx, flowVersion) @@ -60,7 +76,7 @@ export class Extension { this.#commands = new CommandController(this.#dependencyInstaller) // Initialize TestProvider - const extensionPath = ctx?.extensionPath ?? '' + const extensionPath = ctx.extensionPath ?? '' const parserLocation = path.resolve(extensionPath, 'out/extension/cadence-parser.wasm') this.#testProvider = new TestProvider(parserLocation, settings, flowConfig) } @@ -70,8 +86,4 @@ export class Extension { await this.languageServer.deactivate() this.#testProvider?.dispose() } - - async executeCommand (command: string): Promise { - return await this.#commands.executeCommand(command) - } } diff --git a/extension/src/main.ts b/extension/src/main.ts index 0a1f55b4..ca70af88 100644 --- a/extension/src/main.ts +++ b/extension/src/main.ts @@ -31,8 +31,3 @@ export function deactivate (): Thenable | undefined { void Telemetry.deactivate() return (ext === undefined ? undefined : ext?.deactivate()) } - -export async function testActivate (settings: Settings): Promise { - ext = Extension.initialize(settings) - return ext -} diff --git a/extension/src/storage/storage-provider.ts b/extension/src/storage/storage-provider.ts new file mode 100644 index 00000000..a7fd04ee --- /dev/null +++ b/extension/src/storage/storage-provider.ts @@ -0,0 +1,21 @@ +import { Memento } from 'vscode' + +interface State { + dismissedNotifications: string[] +} + +export class StorageProvider { + #globalState: Memento + + constructor (globalState: Memento) { + this.#globalState = globalState + } + + get(key: T, fallback: State[T]): State[T] { + return this.#globalState.get(key, fallback) + } + + async set(key: T, value: State[T]): Promise { + return await (this.#globalState.update(key, value) as Promise) + } +} diff --git a/extension/src/ui/notifications.ts b/extension/src/ui/notifications.ts new file mode 100644 index 00000000..afeaf22f --- /dev/null +++ b/extension/src/ui/notifications.ts @@ -0,0 +1,96 @@ +import { StorageProvider } from '../storage/storage-provider' +import { promptUserErrorMessage, promptUserInfoMessage, promptUserWarningMessage } from './prompts' +import * as vscode from 'vscode' +import * as semver from 'semver' + +const NOTIFICATIONS_URL = 'https://raw.githubusercontent.com/onflow/vscode-cadence/.metadata/notifications.json' + +export interface Notification { + _type: 'Notification' + id: string + type: 'error' | 'info' | 'warning' + text: string + buttons?: Array<{ + label: string + link: string + }> + suppressable?: boolean + compatibility?: { + 'vscode-cadence'?: string + 'flow-cli'?: string + } +} + +export function displayNotifications (notifications: Notification[], storageProvider: StorageProvider): void { + notifications.forEach(notification => { + displayNotification(notification, storageProvider) + }) +} + +export function displayNotification (notification: Notification, storageProvider: StorageProvider): void { + const transformButton = (button: { label: string, link: string }) => { + return { + label: button.label, + callback: () => { + void vscode.env.openExternal(vscode.Uri.parse(button.link)) + } + } + } + const transformButtons = (buttons?: Array<{ label: string, link: string }>): Array<{ label: string, callback: () => void }> => { + return [{ + label: 'Don\'t show again', + callback: () => { + dismissNotification(notification, storageProvider) + } + }].concat(buttons?.map(transformButton) ?? []) + } + + if (notification.type === 'error') { + promptUserErrorMessage(notification.text, transformButtons(notification.buttons)) + } else if (notification.type === 'info') { + promptUserInfoMessage(notification.text, transformButtons(notification.buttons)) + } else if (notification.type === 'warning') { + promptUserWarningMessage(notification.text, transformButtons(notification.buttons)) + } +} + +export function filterNotifications (notifications: Notification[], storageProvider: StorageProvider, currentVersions: { 'vscode-cadence': string, 'flow-cli': string }): Notification[] { + return notifications.filter(notification => { + if (notification.suppressable && isNotificationDismissed(notification, storageProvider)) { + return false + } + + // Check compatibility filters + const satisfies = (version: string, range?: string): boolean => { + if (range == null) return true + return semver.satisfies(version, range, { includePrerelease: true }) + } + const allSatisfied = Object.keys(currentVersions).every((key) => { + return satisfies(currentVersions[key as keyof typeof currentVersions], notification.compatibility?.[key as keyof typeof notification.compatibility]) + }) + + if (!allSatisfied) { + return false + } + + return true + }) +} + +export async function fetchNotifications (filterNotifications: (notifications: Notification[]) => Notification[]): Promise { + return await fetch(NOTIFICATIONS_URL).then(async res => await res.json()).then((notifications: Notification[]) => { + return filterNotifications(notifications) + }).catch(() => { + return [] + }) +} + +export function dismissNotification (notification: Notification, storageProvider: StorageProvider): void { + const dismissedNotifications = storageProvider.get('dismissedNotifications', []) + void storageProvider.set('dismissedNotifications', [...dismissedNotifications, notification.id]) +} + +export function isNotificationDismissed (notification: Notification, storageProvider: StorageProvider): boolean { + const dismissedNotifications = storageProvider.get('dismissedNotifications', []) + return dismissedNotifications.includes(notification.id) +} diff --git a/extension/src/ui/prompts.ts b/extension/src/ui/prompts.ts index 594d5962..e16287a7 100644 --- a/extension/src/ui/prompts.ts +++ b/extension/src/ui/prompts.ts @@ -1,24 +1,43 @@ /* Information and error prompts */ import { window } from 'vscode' -export function promptUserInfoMessage (message: string, buttonText: string, callback: Function): void { +export interface PromptButton { + label: string + callback: Function +} + +export function promptUserInfoMessage (message: string, buttons: PromptButton[] = []): void { window.showInformationMessage( message, - buttonText + ...buttons.map((button) => button.label) ).then((choice) => { - if (choice === buttonText) { - callback() + const button = buttons.find((button) => button.label === choice) + if (button != null) { + button.callback() } }, () => {}) } -export function promptUserErrorMessage (message: string, buttonText: string, callback: Function): void { +export function promptUserErrorMessage (message: string, buttons: PromptButton[] = []): void { window.showErrorMessage( message, - buttonText + ...buttons.map((button) => button.label) + ).then((choice) => { + const button = buttons.find((button) => button.label === choice) + if (button != null) { + button.callback() + } + }, () => {}) +} + +export function promptUserWarningMessage (message: string, buttons: PromptButton[] = []): void { + window.showWarningMessage( + message, + ...buttons.map((button) => button.label) ).then((choice) => { - if (choice === buttonText) { - callback() + const button = buttons.find((button) => button.label === choice) + if (button != null) { + button.callback() } }, () => {}) } diff --git a/extension/test/integration/2 - commands.test.ts b/extension/test/integration/2 - commands.test.ts index 6fb426ce..7ed491d7 100644 --- a/extension/test/integration/2 - commands.test.ts +++ b/extension/test/integration/2 - commands.test.ts @@ -3,24 +3,37 @@ import { Settings } from '../../src/settings/settings' import { MaxTimeout } from '../globals' import { before, after } from 'mocha' import * as assert from 'assert' -import { ext, testActivate } from '../../src/main' import * as commands from '../../src/commands/command-constants' +import { CommandController } from '../../src/commands/command-controller' +import { DependencyInstaller } from '../../src/dependency-installer/dependency-installer' +import * as sinon from 'sinon' suite('Extension Commands', () => { let settings: Settings + let checkDependenciesStub: sinon.SinonStub + let mockDependencyInstaller: DependencyInstaller + let commandController: CommandController before(async function () { this.timeout(MaxTimeout) settings = getMockSettings() - await testActivate(settings) + + // Initialize the command controller & mock dependencies + checkDependenciesStub = sinon.stub() + mockDependencyInstaller = { + checkDependencies: checkDependenciesStub + } as any + commandController = new CommandController(mockDependencyInstaller) }) after(async function () { this.timeout(MaxTimeout) - await ext?.deactivate() }) test('Command: Check Dependencies', async () => { - assert.strictEqual(await ext?.executeCommand(commands.CHECK_DEPENDENCIES), true) + assert.ok(commandController.executeCommand(commands.CHECK_DEPENDENCIES)) + + // Check that the dependency installer was called to check dependencies + assert.ok(checkDependenciesStub.calledOnce) }).timeout(MaxTimeout) }) diff --git a/foo.cdc b/foo.cdc deleted file mode 100644 index e69de29b..00000000 From 71dfc4b7171846b8d1cca574737ec0663fe6292a Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 8 Jan 2024 15:13:49 -0800 Subject: [PATCH 2/9] fix tests & format --- extension/src/extension.ts | 8 ++++---- extension/src/ui/notifications.ts | 4 ++-- extension/test/integration/2 - commands.test.ts | 4 ---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/extension/src/extension.ts b/extension/src/extension.ts index ece47bba..627db45c 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -38,13 +38,13 @@ export class Extension { const storageProvider = new StorageProvider(ctx?.globalState) // Display any notifications from remote server - flowVersion.getValue().then(flowVersion => { + void flowVersion.getValue().then(flowVersion => { if (flowVersion == null) return - const notificationFilter = (notifications: Notification[]) => filterNotifications(notifications, storageProvider, { - 'vscode-cadence': this.ctx.extension.packageJSON.version ?? '0.0.0', + const notificationFilter = (notifications: Notification[]): Notification[] => filterNotifications(notifications, storageProvider, { + 'vscode-cadence': this.ctx.extension.packageJSON.version, 'flow-cli': flowVersion.version }) - fetchNotifications(notificationFilter).then(notifications => { + void fetchNotifications(notificationFilter).then(notifications => { displayNotifications(notifications, storageProvider) }) }) diff --git a/extension/src/ui/notifications.ts b/extension/src/ui/notifications.ts index afeaf22f..d8116c0d 100644 --- a/extension/src/ui/notifications.ts +++ b/extension/src/ui/notifications.ts @@ -28,7 +28,7 @@ export function displayNotifications (notifications: Notification[], storageProv } export function displayNotification (notification: Notification, storageProvider: StorageProvider): void { - const transformButton = (button: { label: string, link: string }) => { + const transformButton = (button: { label: string, link: string }): { label: string, callback: () => void } => { return { label: button.label, callback: () => { @@ -56,7 +56,7 @@ export function displayNotification (notification: Notification, storageProvider export function filterNotifications (notifications: Notification[], storageProvider: StorageProvider, currentVersions: { 'vscode-cadence': string, 'flow-cli': string }): Notification[] { return notifications.filter(notification => { - if (notification.suppressable && isNotificationDismissed(notification, storageProvider)) { + if (notification.suppressable === true && isNotificationDismissed(notification, storageProvider)) { return false } diff --git a/extension/test/integration/2 - commands.test.ts b/extension/test/integration/2 - commands.test.ts index 7ed491d7..e87c5a4f 100644 --- a/extension/test/integration/2 - commands.test.ts +++ b/extension/test/integration/2 - commands.test.ts @@ -1,5 +1,3 @@ -import { getMockSettings } from '../mock/mockSettings' -import { Settings } from '../../src/settings/settings' import { MaxTimeout } from '../globals' import { before, after } from 'mocha' import * as assert from 'assert' @@ -9,14 +7,12 @@ import { DependencyInstaller } from '../../src/dependency-installer/dependency-i import * as sinon from 'sinon' suite('Extension Commands', () => { - let settings: Settings let checkDependenciesStub: sinon.SinonStub let mockDependencyInstaller: DependencyInstaller let commandController: CommandController before(async function () { this.timeout(MaxTimeout) - settings = getMockSettings() // Initialize the command controller & mock dependencies checkDependenciesStub = sinon.stub() From dfaadbbb5d3d934ad1e79482f1bcc0dc0fefbcf1 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 8 Jan 2024 15:30:27 -0800 Subject: [PATCH 3/9] fix test --- .../installers/flow-cli-installer.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/extension/src/dependency-installer/installers/flow-cli-installer.ts b/extension/src/dependency-installer/installers/flow-cli-installer.ts index 99b98bba..6d7d62e9 100644 --- a/extension/src/dependency-installer/installers/flow-cli-installer.ts +++ b/extension/src/dependency-installer/installers/flow-cli-installer.ts @@ -87,11 +87,13 @@ export class InstallFlowCLI extends Installer { if (latest != null && latestStr != null && semver.compare(latest, currentVersion) === 1) { promptUserInfoMessage( 'There is a new Flow CLI version available: ' + latestStr, - 'Install latest Flow CLI', - async () => { - await this.runInstall() - await this.#context.refreshDependencies() - } + [{ + label: 'Install latest Flow CLI', + callback: async () => { + await this.runInstall() + await this.#context.refreshDependencies() + } + }] ) } } @@ -106,11 +108,13 @@ export class InstallFlowCLI extends Installer { })) { promptUserErrorMessage( 'Incompatible Flow CLI version: ' + version.format(), - 'Install latest Flow CLI', - async () => { - await this.runInstall() - await this.#context.refreshDependencies() - } + [{ + label: 'Install latest Flow CLI', + callback: async () => { + await this.runInstall() + await this.#context.refreshDependencies() + } + }] ) return false } From bab326648ade0f01d05c787f153082dc477705f2 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 12 Jan 2024 11:12:16 -0800 Subject: [PATCH 4/9] Switch to provider class --- extension/src/ui/notification-provider.ts | 118 ++++++++++++++++++++++ extension/src/ui/notifications.ts | 96 ------------------ 2 files changed, 118 insertions(+), 96 deletions(-) create mode 100644 extension/src/ui/notification-provider.ts delete mode 100644 extension/src/ui/notifications.ts diff --git a/extension/src/ui/notification-provider.ts b/extension/src/ui/notification-provider.ts new file mode 100644 index 00000000..81484126 --- /dev/null +++ b/extension/src/ui/notification-provider.ts @@ -0,0 +1,118 @@ +import { StorageProvider } from '../storage/storage-provider' +import { promptUserErrorMessage, promptUserInfoMessage, promptUserWarningMessage } from './prompts' +import * as vscode from 'vscode' +import * as semver from 'semver' + +const NOTIFICATIONS_URL = 'https://raw.githubusercontent.com/onflow/vscode-cadence/.metadata/notifications.json' + +export interface Notification { + _type: 'Notification' + id: string + type: 'error' | 'info' | 'warning' + text: string + buttons?: Array<{ + label: string + link: string + }> + suppressable?: boolean + compatibility?: { + 'vscode-cadence'?: string + 'flow-cli'?: string + } +} + +export class NotificationProvider { + #storageProvider: StorageProvider + + constructor(storageProvider: StorageProvider) { + this.#storageProvider = storageProvider + } + + activate () { + this.#fetchAndDisplayNotifications() + } + + #fetchAndDisplayNotifications = async (): Promise => { + const currentVersions = { + 'vscode-cadence': vscode.extensions.getExtension('onflow.vscode-cadence')?.packageJSON.version ?? '0.0.0', + 'flow-cli': vscode.extensions.getExtension('onflow.flow-cli')?.packageJSON.version ?? '0.0.0' + } + const notifications = await this.#fetchNotifications() + const filteredNotifications = this.#filterNotifications(notifications, this.#storageProvider, currentVersions) + this.#displayNotifications(filteredNotifications) + } + + #displayNotifications (notifications: Notification[]): void { + notifications.forEach(notification => { + this.#displayNotification(notification) + }) + } + + #displayNotification (notification: Notification): void { + const transformButton = (button: { label: string, link: string }): { label: string, callback: () => void } => { + return { + label: button.label, + callback: () => { + void vscode.env.openExternal(vscode.Uri.parse(button.link)) + } + } + } + const transformButtons = (buttons?: Array<{ label: string, link: string }>): Array<{ label: string, callback: () => void }> => { + return [{ + label: 'Don\'t show again', + callback: () => { + this.#dismissNotification(notification) + } + }].concat(buttons?.map(transformButton) ?? []) + } + + if (notification.type === 'error') { + promptUserErrorMessage(notification.text, transformButtons(notification.buttons)) + } else if (notification.type === 'info') { + promptUserInfoMessage(notification.text, transformButtons(notification.buttons)) + } else if (notification.type === 'warning') { + promptUserWarningMessage(notification.text, transformButtons(notification.buttons)) + } + } + + #filterNotifications (notifications: Notification[], storageProvider: StorageProvider, currentVersions: { 'vscode-cadence': string, 'flow-cli': string }): Notification[] { + return notifications.filter(notification => { + if (notification.suppressable === true && this.#isNotificationDismissed(notification)) { + return false + } + + // Check compatibility filters + const satisfies = (version: string, range?: string): boolean => { + if (range == null) return true + return semver.satisfies(version, range, { includePrerelease: true }) + } + const allSatisfied = Object.keys(currentVersions).every((key) => { + return satisfies(currentVersions[key as keyof typeof currentVersions], notification.compatibility?.[key as keyof typeof notification.compatibility]) + }) + + if (!allSatisfied) { + return false + } + + return true + }) + } + + async #fetchNotifications (): Promise { + return await fetch(NOTIFICATIONS_URL).then(async res => await res.json()).then((notifications: Notification[]) => { + return notifications + }).catch(() => { + return [] + }) + } + + #dismissNotification (notification: Notification): void { + const dismissedNotifications = this.#storageProvider.get('dismissedNotifications', []) + void this.#storageProvider.set('dismissedNotifications', [...dismissedNotifications, notification.id]) + } + + #isNotificationDismissed (notification: Notification): boolean { + const dismissedNotifications = this.#storageProvider.get('dismissedNotifications', []) + return dismissedNotifications.includes(notification.id) + } +} \ No newline at end of file diff --git a/extension/src/ui/notifications.ts b/extension/src/ui/notifications.ts deleted file mode 100644 index d8116c0d..00000000 --- a/extension/src/ui/notifications.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { StorageProvider } from '../storage/storage-provider' -import { promptUserErrorMessage, promptUserInfoMessage, promptUserWarningMessage } from './prompts' -import * as vscode from 'vscode' -import * as semver from 'semver' - -const NOTIFICATIONS_URL = 'https://raw.githubusercontent.com/onflow/vscode-cadence/.metadata/notifications.json' - -export interface Notification { - _type: 'Notification' - id: string - type: 'error' | 'info' | 'warning' - text: string - buttons?: Array<{ - label: string - link: string - }> - suppressable?: boolean - compatibility?: { - 'vscode-cadence'?: string - 'flow-cli'?: string - } -} - -export function displayNotifications (notifications: Notification[], storageProvider: StorageProvider): void { - notifications.forEach(notification => { - displayNotification(notification, storageProvider) - }) -} - -export function displayNotification (notification: Notification, storageProvider: StorageProvider): void { - const transformButton = (button: { label: string, link: string }): { label: string, callback: () => void } => { - return { - label: button.label, - callback: () => { - void vscode.env.openExternal(vscode.Uri.parse(button.link)) - } - } - } - const transformButtons = (buttons?: Array<{ label: string, link: string }>): Array<{ label: string, callback: () => void }> => { - return [{ - label: 'Don\'t show again', - callback: () => { - dismissNotification(notification, storageProvider) - } - }].concat(buttons?.map(transformButton) ?? []) - } - - if (notification.type === 'error') { - promptUserErrorMessage(notification.text, transformButtons(notification.buttons)) - } else if (notification.type === 'info') { - promptUserInfoMessage(notification.text, transformButtons(notification.buttons)) - } else if (notification.type === 'warning') { - promptUserWarningMessage(notification.text, transformButtons(notification.buttons)) - } -} - -export function filterNotifications (notifications: Notification[], storageProvider: StorageProvider, currentVersions: { 'vscode-cadence': string, 'flow-cli': string }): Notification[] { - return notifications.filter(notification => { - if (notification.suppressable === true && isNotificationDismissed(notification, storageProvider)) { - return false - } - - // Check compatibility filters - const satisfies = (version: string, range?: string): boolean => { - if (range == null) return true - return semver.satisfies(version, range, { includePrerelease: true }) - } - const allSatisfied = Object.keys(currentVersions).every((key) => { - return satisfies(currentVersions[key as keyof typeof currentVersions], notification.compatibility?.[key as keyof typeof notification.compatibility]) - }) - - if (!allSatisfied) { - return false - } - - return true - }) -} - -export async function fetchNotifications (filterNotifications: (notifications: Notification[]) => Notification[]): Promise { - return await fetch(NOTIFICATIONS_URL).then(async res => await res.json()).then((notifications: Notification[]) => { - return filterNotifications(notifications) - }).catch(() => { - return [] - }) -} - -export function dismissNotification (notification: Notification, storageProvider: StorageProvider): void { - const dismissedNotifications = storageProvider.get('dismissedNotifications', []) - void storageProvider.set('dismissedNotifications', [...dismissedNotifications, notification.id]) -} - -export function isNotificationDismissed (notification: Notification, storageProvider: StorageProvider): boolean { - const dismissedNotifications = storageProvider.get('dismissedNotifications', []) - return dismissedNotifications.includes(notification.id) -} From d7c263b82d81437b50bfd482aa5a8eaf44ec69ac Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 12 Jan 2024 11:15:07 -0800 Subject: [PATCH 5/9] write test --- .../7 - notification-provider.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 extension/test/integration/7 - notification-provider.test.ts diff --git a/extension/test/integration/7 - notification-provider.test.ts b/extension/test/integration/7 - notification-provider.test.ts new file mode 100644 index 00000000..90dc6dc8 --- /dev/null +++ b/extension/test/integration/7 - notification-provider.test.ts @@ -0,0 +1,42 @@ +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { StorageProvider } from "../../src/storage/storage-provider" +import { NotificationProvider } from "../../src/ui/notification-provider" + +suite('notificaitons tests', () => { + let mockGlobalState: vscode.Memento + let storage: StorageProvider + let notifications: NotificationProvider + + let fetchSpy: sinon.SinonSpy + let promptSpy: { + info: sinon.SinonSpy + warning: sinon.SinonSpy + error: sinon.SinonSpy + } + + beforeEach(async function () { + this.timeout(5000) + + let state = new Map() + mockGlobalState = { + get: (key: string) => state.get(key), + update: (key: string, value: any) => state.set(key, value), + } as any + storage = new StorageProvider(mockGlobalState) + notifications = new NotificationProvider(storage) + + fetchSpy = sinon.stub(globalThis, 'fetch') + promptSpy = { + info: sinon.stub(vscode.window, 'showInformationMessage'), + warning: sinon.stub(vscode.window, 'showWarningMessage'), + error: sinon.stub(vscode.window, 'showErrorMessage'), + } + }) + + test('notifications are displayed', async function () { + this.timeout(5000) + + notifications.activate() + }) +}) \ No newline at end of file From f7a0dc43dddc066d1c2cd4ff9cb349b8ddbf8236 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 26 Jan 2024 09:17:05 -0800 Subject: [PATCH 6/9] update --- .metadata/README.md | 11 +-- .metadata/notifications.json | 12 +-- extension/src/extension.ts | 14 +--- extension/src/ui/notification-provider.ts | 74 +++++++------------ .../7 - notification-provider.test.ts | 42 ----------- package-lock.json | 4 +- 6 files changed, 38 insertions(+), 119 deletions(-) delete mode 100644 extension/test/integration/7 - notification-provider.test.ts diff --git a/.metadata/README.md b/.metadata/README.md index 224fd760..daa8a3c1 100644 --- a/.metadata/README.md +++ b/.metadata/README.md @@ -19,10 +19,6 @@ interface Notification { link: string }> suppressable?: boolean - compatibility?: { - 'vscode-cadence'?: string - 'flow-cli'?: string - } } ``` @@ -32,8 +28,5 @@ interface Notification { - `id`: A unique identifier for the notification. This is used to determine if the notification has already been displayed to the user. - `type`: The type of notification. Can be `"info"`, `"warning"`, or `"error"`. - `text`: The text to display to the user. -- `buttons`: An array of buttons to display to the user. Each button should have a `text` field and a `link` field. The `link` field should be a URL to open when the button is clicked. -- `suppressable`: Whether or not the user should be able to suppress the notification. (defaults to `true`) -- `compatibility`: An object containing compatibility information for the notification. If all of the specified compatibility requirements are met, the notification will be displayed to the user. If not, the notification will be ignored. The following compatibility requirements are supported: - - `vscode-cadence`: The version of the Cadence VSCode Extension that the user must be running. Can be a specific version number (e.g. `"0.0.1"`) or a semver range (e.g. `"^0.0.1"`). - - `flow-cli`: The version of the Flow CLI that the user must be running. Can be a specific version number (e.g. `"0.25.0"`) or a semver range (e.g. `"^0.25.0"`). \ No newline at end of file +- `buttons`: An array of buttons to display to the user. Each button should have a `label` field and a `link` field. The `link` field should be a URL to open when the button is clicked. +- `suppressable`: Whether or not the user should be able to suppress the notification. (defaults to `true`) \ No newline at end of file diff --git a/.metadata/notifications.json b/.metadata/notifications.json index 9217ecf6..093d3023 100644 --- a/.metadata/notifications.json +++ b/.metadata/notifications.json @@ -3,17 +3,13 @@ "_type": "Notification", "id": "1", "type": "info", - "text": "Cadence 1.0 pre-release builds are now available! Developers should begin upgrading their projects - see the Cadence 1.0 Upgrade Plan for more details.", + "text": "Cadence 1.0 pre-release builds are now available! Developers should begin upgrading their projects - see the Cadence 1.0 Migration Guide for more details.", "buttons": [ { - "text": "Learn More", - "link": "https://forum.flow.com/t/cadence-1-0-upgrade-plan/5477#what-does-it-mean-for-me-if-i-am-a-2" + "label": "Learn More", + "link": "https://cadence-lang.org/docs/cadence-migration-guide" } ], - "suppressable": true, - "versions": { - "vscode-cadence": "*", - "flow-cli": "*" - } + "suppressable": false } ] \ No newline at end of file diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 627db45c..442f5e14 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -11,7 +11,7 @@ import { FlowConfig } from './server/flow-config' import { TestProvider } from './test-provider/test-provider' import { StorageProvider } from './storage/storage-provider' import * as path from 'path' -import { Notification, displayNotifications, fetchNotifications, filterNotifications } from './ui/notifications' +import { NotificationProvider } from './ui/notification-provider' // The container for all data relevant to the extension. export class Extension { @@ -38,16 +38,8 @@ export class Extension { const storageProvider = new StorageProvider(ctx?.globalState) // Display any notifications from remote server - void flowVersion.getValue().then(flowVersion => { - if (flowVersion == null) return - const notificationFilter = (notifications: Notification[]): Notification[] => filterNotifications(notifications, storageProvider, { - 'vscode-cadence': this.ctx.extension.packageJSON.version, - 'flow-cli': flowVersion.version - }) - void fetchNotifications(notificationFilter).then(notifications => { - displayNotifications(notifications, storageProvider) - }) - }) + const notificationProvider = new NotificationProvider(storageProvider) + notificationProvider.activate() // Register JSON schema provider if (ctx != null) JSONSchemaProvider.register(ctx, flowVersion) diff --git a/extension/src/ui/notification-provider.ts b/extension/src/ui/notification-provider.ts index 81484126..cc33dc77 100644 --- a/extension/src/ui/notification-provider.ts +++ b/extension/src/ui/notification-provider.ts @@ -1,7 +1,6 @@ import { StorageProvider } from '../storage/storage-provider' import { promptUserErrorMessage, promptUserInfoMessage, promptUserWarningMessage } from './prompts' import * as vscode from 'vscode' -import * as semver from 'semver' const NOTIFICATIONS_URL = 'https://raw.githubusercontent.com/onflow/vscode-cadence/.metadata/notifications.json' @@ -15,37 +14,29 @@ export interface Notification { link: string }> suppressable?: boolean - compatibility?: { - 'vscode-cadence'?: string - 'flow-cli'?: string - } } export class NotificationProvider { #storageProvider: StorageProvider - constructor(storageProvider: StorageProvider) { + constructor( + storageProvider: StorageProvider + ) { this.#storageProvider = storageProvider } activate () { - this.#fetchAndDisplayNotifications() + void this.#fetchAndDisplayNotifications() } - #fetchAndDisplayNotifications = async (): Promise => { - const currentVersions = { - 'vscode-cadence': vscode.extensions.getExtension('onflow.vscode-cadence')?.packageJSON.version ?? '0.0.0', - 'flow-cli': vscode.extensions.getExtension('onflow.flow-cli')?.packageJSON.version ?? '0.0.0' - } + async #fetchAndDisplayNotifications (): Promise { + // Fetch notifications const notifications = await this.#fetchNotifications() - const filteredNotifications = this.#filterNotifications(notifications, this.#storageProvider, currentVersions) - this.#displayNotifications(filteredNotifications) - } - - #displayNotifications (notifications: Notification[]): void { - notifications.forEach(notification => { - this.#displayNotification(notification) - }) + + // Display all valid notifications + notifications + .filter(this.#notificationFilter.bind(this)) + .forEach(this.#displayNotification.bind(this)) } #displayNotification (notification: Notification): void { @@ -57,45 +48,34 @@ export class NotificationProvider { } } } - const transformButtons = (buttons?: Array<{ label: string, link: string }>): Array<{ label: string, callback: () => void }> => { - return [{ + + // Transform buttons + let buttons: Array<{ label: string, callback: () => void }> = [] + if (notification.suppressable === true) { + buttons = [{ label: 'Don\'t show again', callback: () => { this.#dismissNotification(notification) } - }].concat(buttons?.map(transformButton) ?? []) + }] } + buttons = buttons?.concat(notification.buttons?.map(transformButton) ?? []) if (notification.type === 'error') { - promptUserErrorMessage(notification.text, transformButtons(notification.buttons)) + promptUserErrorMessage(notification.text, buttons) } else if (notification.type === 'info') { - promptUserInfoMessage(notification.text, transformButtons(notification.buttons)) + promptUserInfoMessage(notification.text, buttons) } else if (notification.type === 'warning') { - promptUserWarningMessage(notification.text, transformButtons(notification.buttons)) + promptUserWarningMessage(notification.text, buttons) } } - #filterNotifications (notifications: Notification[], storageProvider: StorageProvider, currentVersions: { 'vscode-cadence': string, 'flow-cli': string }): Notification[] { - return notifications.filter(notification => { - if (notification.suppressable === true && this.#isNotificationDismissed(notification)) { - return false - } - - // Check compatibility filters - const satisfies = (version: string, range?: string): boolean => { - if (range == null) return true - return semver.satisfies(version, range, { includePrerelease: true }) - } - const allSatisfied = Object.keys(currentVersions).every((key) => { - return satisfies(currentVersions[key as keyof typeof currentVersions], notification.compatibility?.[key as keyof typeof notification.compatibility]) - }) - - if (!allSatisfied) { - return false - } - - return true - }) + #notificationFilter (notification: Notification): boolean { + if (notification.suppressable === true && this.#isNotificationDismissed(notification)) { + return false + } + + return true } async #fetchNotifications (): Promise { diff --git a/extension/test/integration/7 - notification-provider.test.ts b/extension/test/integration/7 - notification-provider.test.ts deleted file mode 100644 index 90dc6dc8..00000000 --- a/extension/test/integration/7 - notification-provider.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as vscode from 'vscode' -import * as sinon from 'sinon' -import { StorageProvider } from "../../src/storage/storage-provider" -import { NotificationProvider } from "../../src/ui/notification-provider" - -suite('notificaitons tests', () => { - let mockGlobalState: vscode.Memento - let storage: StorageProvider - let notifications: NotificationProvider - - let fetchSpy: sinon.SinonSpy - let promptSpy: { - info: sinon.SinonSpy - warning: sinon.SinonSpy - error: sinon.SinonSpy - } - - beforeEach(async function () { - this.timeout(5000) - - let state = new Map() - mockGlobalState = { - get: (key: string) => state.get(key), - update: (key: string, value: any) => state.set(key, value), - } as any - storage = new StorageProvider(mockGlobalState) - notifications = new NotificationProvider(storage) - - fetchSpy = sinon.stub(globalThis, 'fetch') - promptSpy = { - info: sinon.stub(vscode.window, 'showInformationMessage'), - warning: sinon.stub(vscode.window, 'showWarningMessage'), - error: sinon.stub(vscode.window, 'showErrorMessage'), - } - }) - - test('notifications are displayed', async function () { - this.timeout(5000) - - notifications.activate() - }) -}) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index be908117..bad7bc46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cadence", - "version": "2.1.1", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cadence", - "version": "2.1.1", + "version": "2.2.0", "dependencies": { "@onflow/cadence-parser": "^0.42.1", "@onflow/decode": "0.0.11", From 478a4deaf7eca2231f9b16c0b0eeca005b1bd769 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 26 Jan 2024 10:57:37 -0800 Subject: [PATCH 7/9] format --- extension/src/ui/notification-provider.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extension/src/ui/notification-provider.ts b/extension/src/ui/notification-provider.ts index cc33dc77..52be8e48 100644 --- a/extension/src/ui/notification-provider.ts +++ b/extension/src/ui/notification-provider.ts @@ -19,20 +19,20 @@ export interface Notification { export class NotificationProvider { #storageProvider: StorageProvider - constructor( + constructor ( storageProvider: StorageProvider ) { this.#storageProvider = storageProvider } - activate () { + activate (): void { void this.#fetchAndDisplayNotifications() } async #fetchAndDisplayNotifications (): Promise { // Fetch notifications const notifications = await this.#fetchNotifications() - + // Display all valid notifications notifications .filter(this.#notificationFilter.bind(this)) @@ -60,7 +60,7 @@ export class NotificationProvider { }] } buttons = buttons?.concat(notification.buttons?.map(transformButton) ?? []) - + if (notification.type === 'error') { promptUserErrorMessage(notification.text, buttons) } else if (notification.type === 'info') { @@ -95,4 +95,4 @@ export class NotificationProvider { const dismissedNotifications = this.#storageProvider.get('dismissedNotifications', []) return dismissedNotifications.includes(notification.id) } -} \ No newline at end of file +} From 2ef4d05885c156dbd3272960fffdcf5084e7b538 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 8 Feb 2024 13:27:04 -0800 Subject: [PATCH 8/9] format --- extension/src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 48b2e243..527c494f 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -40,7 +40,7 @@ export class Extension { // Display any notifications from remote server const notificationProvider = new NotificationProvider(storageProvider) notificationProvider.activate() - + // Register Flow version provider const flowVersionProvider = new FlowVersionProvider(settings) From 12060b8c397d2402b3d4d908ca43284599127557 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 9 Feb 2024 16:06:48 -0800 Subject: [PATCH 9/9] fix url --- extension/src/ui/notification-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/ui/notification-provider.ts b/extension/src/ui/notification-provider.ts index 52be8e48..c17080ed 100644 --- a/extension/src/ui/notification-provider.ts +++ b/extension/src/ui/notification-provider.ts @@ -2,7 +2,7 @@ import { StorageProvider } from '../storage/storage-provider' import { promptUserErrorMessage, promptUserInfoMessage, promptUserWarningMessage } from './prompts' import * as vscode from 'vscode' -const NOTIFICATIONS_URL = 'https://raw.githubusercontent.com/onflow/vscode-cadence/.metadata/notifications.json' +const NOTIFICATIONS_URL = 'https://raw.githubusercontent.com/onflow/vscode-cadence/master/.metadata/notifications.json' export interface Notification { _type: 'Notification'