Skip to content

Commit

Permalink
Add startup notificaitons defined by remote GH source
Browse files Browse the repository at this point in the history
  • Loading branch information
jribbink committed Jan 6, 2024
1 parent 3398955 commit e71d6fd
Show file tree
Hide file tree
Showing 10 changed files with 250 additions and 32 deletions.
39 changes: 39 additions & 0 deletions .metadata/README.md
Original file line number Diff line number Diff line change
@@ -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"`).
19 changes: 19 additions & 0 deletions .metadata/notifications.json
Original file line number Diff line number Diff line change
@@ -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": "*"
}
}
]
10 changes: 7 additions & 3 deletions extension/src/dependency-installer/dependency-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
]
)
}
})
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
await new Promise<void>((resolve) => {
setTimeout(() => { resolve() }, 2000)
})

Expand Down
36 changes: 24 additions & 12 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,31 +9,46 @@ 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 {
// The extension singleton
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)

Expand Down Expand Up @@ -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)
}
Expand All @@ -70,8 +86,4 @@ export class Extension {
await this.languageServer.deactivate()
this.#testProvider?.dispose()
}

async executeCommand (command: string): Promise<boolean> {
return await this.#commands.executeCommand(command)
}
}
5 changes: 0 additions & 5 deletions extension/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,3 @@ export function deactivate (): Thenable<void> | undefined {
void Telemetry.deactivate()
return (ext === undefined ? undefined : ext?.deactivate())
}

export async function testActivate (settings: Settings): Promise<Extension> {
ext = Extension.initialize(settings)
return ext
}
21 changes: 21 additions & 0 deletions extension/src/storage/storage-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Memento } from 'vscode'

interface State {
dismissedNotifications: string[]
}

export class StorageProvider {
#globalState: Memento

constructor (globalState: Memento) {
this.#globalState = globalState
}

get<T extends keyof State>(key: T, fallback: State[T]): State[T] {
return this.#globalState.get(key, fallback)
}

async set<T extends keyof State>(key: T, value: State[T]): Promise<void> {
return await (this.#globalState.update(key, value) as Promise<void>)
}
}
96 changes: 96 additions & 0 deletions extension/src/ui/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<Notification[]> {
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)
}
35 changes: 27 additions & 8 deletions extension/src/ui/prompts.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}, () => {})
}
21 changes: 17 additions & 4 deletions extension/test/integration/2 - commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Empty file removed foo.cdc
Empty file.

0 comments on commit e71d6fd

Please sign in to comment.