Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add startup notifications defined by remote GH source #502

Merged
merged 10 commits into from
Feb 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading