diff --git a/.metadata/README.md b/.metadata/README.md new file mode 100644 index 00000000..daa8a3c1 --- /dev/null +++ b/.metadata/README.md @@ -0,0 +1,32 @@ +# 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 +} +``` + +### 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 `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 new file mode 100644 index 00000000..093d3023 --- /dev/null +++ b/.metadata/notifications.json @@ -0,0 +1,15 @@ +[ + { + "_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 Migration Guide for more details.", + "buttons": [ + { + "label": "Learn More", + "link": "https://cadence-lang.org/docs/cadence-migration-guide" + } + ], + "suppressable": false + } +] \ 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 a1d72a3d..6c154061 100644 --- a/extension/src/dependency-installer/dependency-installer.ts +++ b/extension/src/dependency-installer/dependency-installer.ts @@ -40,8 +40,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() } + } + ] ) } }) @@ -76,7 +80,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/dependency-installer/installers/flow-cli-installer.ts b/extension/src/dependency-installer/installers/flow-cli-installer.ts index 43401e88..79ae0045 100644 --- a/extension/src/dependency-installer/installers/flow-cli-installer.ts +++ b/extension/src/dependency-installer/installers/flow-cli-installer.ts @@ -86,11 +86,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 } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index f4957d62..527c494f 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 { JSONSchemaProvider } from './json-schema-provider' 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 { NotificationProvider } from './ui/notification-provider' // The container for all data relevant to the extension. export class Extension { @@ -18,21 +19,28 @@ 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 + const notificationProvider = new NotificationProvider(storageProvider) + notificationProvider.activate() + // Register Flow version provider const flowVersionProvider = new FlowVersionProvider(settings) @@ -63,7 +71,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) } @@ -73,8 +81,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/notification-provider.ts b/extension/src/ui/notification-provider.ts new file mode 100644 index 00000000..c17080ed --- /dev/null +++ b/extension/src/ui/notification-provider.ts @@ -0,0 +1,98 @@ +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/master/.metadata/notifications.json' + +export interface Notification { + _type: 'Notification' + id: string + type: 'error' | 'info' | 'warning' + text: string + buttons?: Array<{ + label: string + link: string + }> + suppressable?: boolean +} + +export class NotificationProvider { + #storageProvider: StorageProvider + + constructor ( + storageProvider: StorageProvider + ) { + this.#storageProvider = storageProvider + } + + 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)) + .forEach(this.#displayNotification.bind(this)) + } + + #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)) + } + } + } + + // Transform buttons + let buttons: Array<{ label: string, callback: () => void }> = [] + if (notification.suppressable === true) { + buttons = [{ + label: 'Don\'t show again', + callback: () => { + this.#dismissNotification(notification) + } + }] + } + buttons = buttons?.concat(notification.buttons?.map(transformButton) ?? []) + + if (notification.type === 'error') { + promptUserErrorMessage(notification.text, buttons) + } else if (notification.type === 'info') { + promptUserInfoMessage(notification.text, buttons) + } else if (notification.type === 'warning') { + promptUserWarningMessage(notification.text, buttons) + } + } + + #notificationFilter (notification: Notification): boolean { + if (notification.suppressable === true && this.#isNotificationDismissed(notification)) { + 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) + } +} 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..e87c5a4f 100644 --- a/extension/test/integration/2 - commands.test.ts +++ b/extension/test/integration/2 - commands.test.ts @@ -1,26 +1,35 @@ -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' -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