Skip to content

Commit

Permalink
Add startup notifications defined by remote GH source (#502)
Browse files Browse the repository at this point in the history
  • Loading branch information
jribbink authored Feb 10, 2024
1 parent 94d0811 commit 4dcf98d
Show file tree
Hide file tree
Showing 11 changed files with 247 additions and 46 deletions.
32 changes: 32 additions & 0 deletions .metadata/README.md
Original file line number Diff line number Diff line change
@@ -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`)
15 changes: 15 additions & 0 deletions .metadata/notifications.json
Original file line number Diff line number Diff line change
@@ -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
}
]
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 @@ -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() }
}
]
)
}
})
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
await new Promise<void>((resolve) => {
setTimeout(() => { resolve() }, 2000)
})

Expand Down
24 changes: 14 additions & 10 deletions extension/src/dependency-installer/installers/flow-cli-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}]
)
}
}
Expand All @@ -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
}
Expand Down
28 changes: 16 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,38 @@ 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 {
// 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
const notificationProvider = new NotificationProvider(storageProvider)
notificationProvider.activate()

// Register Flow version provider
const flowVersionProvider = new FlowVersionProvider(settings)

Expand Down Expand Up @@ -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)
}
Expand All @@ -73,8 +81,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>)
}
}
98 changes: 98 additions & 0 deletions extension/src/ui/notification-provider.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<Notification[]> {
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)
}
}
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()
}
}, () => {})
}
Loading

0 comments on commit 4dcf98d

Please sign in to comment.