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