From fba4c241811e944852153e0ac9ccf51a7a12d7eb Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:56:42 +0000 Subject: [PATCH] add 'add binding' command --- .../package.json | 12 +- .../resources/icons/d1.svg | 1 + .../resources/icons/kv.svg | 1 + .../resources/icons/r2.svg | 1 + .../src/add-binding.ts | 361 ++++++++++++++++++ .../src/bindings.ts | 19 +- .../src/extension.ts | 8 +- .../src/wrangler.ts | 1 + 8 files changed, 394 insertions(+), 10 deletions(-) create mode 100644 packages/cloudflare-workers-bindings-extension/resources/icons/d1.svg create mode 100644 packages/cloudflare-workers-bindings-extension/resources/icons/kv.svg create mode 100644 packages/cloudflare-workers-bindings-extension/resources/icons/r2.svg create mode 100644 packages/cloudflare-workers-bindings-extension/src/add-binding.ts diff --git a/packages/cloudflare-workers-bindings-extension/package.json b/packages/cloudflare-workers-bindings-extension/package.json index 94b1b21f76c8..ac5ab7d280d0 100644 --- a/packages/cloudflare-workers-bindings-extension/package.json +++ b/packages/cloudflare-workers-bindings-extension/package.json @@ -31,6 +31,11 @@ "command": "cloudflare-workers-bindings.refresh", "title": "Cloudflare Workers: Refresh bindings", "icon": "$(refresh)" + }, + { + "command": "cloudflare-workers-bindings.addEntry", + "title": "Cloudflare Workers: Add binding", + "icon": "$(add)" } ], "menus": { @@ -39,6 +44,11 @@ "command": "cloudflare-workers-bindings.refresh", "when": "view == cloudflare-workers-bindings", "group": "navigation" + }, + { + "command": "cloudflare-workers-bindings.addEntry", + "when": "view == cloudflare-workers-bindings", + "group": "navigation" } ] }, @@ -64,7 +74,7 @@ "viewsWelcome": [ { "view": "cloudflare-workers-bindings", - "contents": "Welcome to Cloudflare Workers! [Learn more](https://workers.cloudflare.com).\n[Refresh Bindings](command:cloudflare-workers-bindings.refresh)" + "contents": "Welcome to Cloudflare Workers! [Learn more](https://workers.cloudflare.com).\n[Add a binding](command:cloudflare-workers-bindings.addEntry)" } ] }, diff --git a/packages/cloudflare-workers-bindings-extension/resources/icons/d1.svg b/packages/cloudflare-workers-bindings-extension/resources/icons/d1.svg new file mode 100644 index 000000000000..e1943befd6f4 --- /dev/null +++ b/packages/cloudflare-workers-bindings-extension/resources/icons/d1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/cloudflare-workers-bindings-extension/resources/icons/kv.svg b/packages/cloudflare-workers-bindings-extension/resources/icons/kv.svg new file mode 100644 index 000000000000..aa5283cba381 --- /dev/null +++ b/packages/cloudflare-workers-bindings-extension/resources/icons/kv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/cloudflare-workers-bindings-extension/resources/icons/r2.svg b/packages/cloudflare-workers-bindings-extension/resources/icons/r2.svg new file mode 100644 index 000000000000..7eb01e06b7cd --- /dev/null +++ b/packages/cloudflare-workers-bindings-extension/resources/icons/r2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/cloudflare-workers-bindings-extension/src/add-binding.ts b/packages/cloudflare-workers-bindings-extension/src/add-binding.ts new file mode 100644 index 000000000000..d7900ba30baa --- /dev/null +++ b/packages/cloudflare-workers-bindings-extension/src/add-binding.ts @@ -0,0 +1,361 @@ +import { + Disposable, + env, + ExtensionContext, + QuickInput, + QuickInputButton, + QuickInputButtons, + QuickPickItem, + Uri, + window, + workspace, +} from "vscode"; +import { getConfigUri } from "./bindings"; +import { importWrangler } from "./wrangler"; + +class BindingType implements QuickPickItem { + constructor( + public label: string, + public description?: string, + public detail?: string, + public iconPath?: Uri + ) {} +} +/** + * A multi-step input using window.createQuickPick() and window.createInputBox(). + * + * This first part uses the helper class `MultiStepInput` that wraps the API for the multi-step case. + */ +export async function multiStepInput( + context: ExtensionContext + // rootPath: string +) { + const bindingTypes: BindingType[] = [ + new BindingType( + "KV", + "kv_namespaces", + "Global, low-latency, key-value data storage", + Uri.file(context.asAbsolutePath("resources/icons/kv.svg")) + ), + new BindingType( + "R2", + "r2_buckets", + "Object storage for all your data", + Uri.file(context.asAbsolutePath("resources/icons/r2.svg")) + ), + new BindingType( + "D1", + "d1_databases", + "Serverless SQL databases", + Uri.file(context.asAbsolutePath("resources/icons/d1.svg")) + ), + ]; + + interface State { + title: string; + step: number; + totalSteps: number; + bindingType: BindingType; + name: string; + runtime: QuickPickItem; + id: string; + } + + async function collectInputs() { + const state = {} as Partial; + await MultiStepInput.run((input) => pickResourceGroup(input, state)); + return state as State; + } + + const title = "Add binding"; + + async function pickResourceGroup( + input: MultiStepInput, + state: Partial + ) { + const pick = await input.showQuickPick({ + title, + step: 1, + totalSteps: 2, + placeholder: "Choose a binding type", + items: bindingTypes, + activeItem: + typeof state.bindingType !== "string" ? state.bindingType : undefined, + // shouldResume, + }); + state.bindingType = pick as BindingType; + return (input: MultiStepInput) => inputName(input, state); + } + + async function inputName(input: MultiStepInput, state: Partial) { + // TODO: Remember current value when navigating back. + + let name = await input.showInputBox({ + title, + step: 2, + totalSteps: 2, + value: state.name || "", + prompt: "Choose a binding name", + validate: validateNameIsUnique, + placeholder: `e.g. MY_BINDING`, + // shouldResume, + }); + state.name = name; + return () => addToToml(state); + } + + async function addToToml(state: Partial) { + const configUri = await getConfigUri(); + if (!configUri) { + return null; + } + const workspaceFolder = workspace.getWorkspaceFolder(configUri); + + if (!workspaceFolder) { + return null; + } + + const wrangler = importWrangler(workspaceFolder.uri.fsPath); + + await workspace.openTextDocument(configUri).then((doc) => { + window.showTextDocument(doc); + try { + wrangler.experimental_patchConfig(configUri.path, { + [state.bindingType?.description!]: [{ binding: state.name! }], + }); + window.showInformationMessage(`Created binding '${state.name}'`); + } catch { + window.showErrorMessage( + `Unable to directly add binding to config file. A snippet has been copied to clipboard - please paste this into your config file.` + ); + + const patch = `[[${state.bindingType?.description!}]] +binding = "${state.name}" +`; + + env.clipboard.writeText(patch); + } + }); + } + + async function validateNameIsUnique(name: string) { + // TODO: actually validate uniqueness + return name === "SOME_KV_BINDING" ? "Name not unique" : undefined; + } + + const state = await collectInputs(); +} + +// ------------------------------------------------------- +// Helper code that wraps the API for the multi-step case. +// ------------------------------------------------------- + +class InputFlowAction { + static back = new InputFlowAction(); + static cancel = new InputFlowAction(); + static resume = new InputFlowAction(); +} + +type InputStep = (input: MultiStepInput) => Thenable; + +interface QuickPickParameters { + title: string; + step: number; + totalSteps: number; + items: T[]; + activeItem?: T; + ignoreFocusOut?: boolean; + placeholder: string; + buttons?: QuickInputButton[]; +} + +interface InputBoxParameters { + title: string; + step: number; + totalSteps: number; + value: string; + prompt: string; + validate: (value: string) => Promise; + buttons?: QuickInputButton[]; + ignoreFocusOut?: boolean; + placeholder?: string; + // shouldResume: () => Thenable; +} + +export class MultiStepInput { + static async run(start: InputStep) { + const input = new MultiStepInput(); + return input.stepThrough(start); + } + + private current?: QuickInput; + private steps: InputStep[] = []; + + private async stepThrough(start: InputStep) { + let step: InputStep | void = start; + while (step) { + this.steps.push(step); + if (this.current) { + this.current.enabled = false; + this.current.busy = true; + } + try { + step = await step(this); + } catch (err) { + if (err === InputFlowAction.back) { + this.steps.pop(); + step = this.steps.pop(); + } else if (err === InputFlowAction.resume) { + step = this.steps.pop(); + } else if (err === InputFlowAction.cancel) { + step = undefined; + } else { + throw err; + } + } + } + if (this.current) { + this.current.dispose(); + } + } + + async showQuickPick< + T extends QuickPickItem, + P extends QuickPickParameters, + >({ + title, + step, + totalSteps, + items, + activeItem, + ignoreFocusOut, + placeholder, + buttons, + // shouldResume, + }: P) { + const disposables: Disposable[] = []; + try { + return await new Promise< + T | (P extends { buttons: (infer I)[] } ? I : never) + >((resolve, reject) => { + const input = window.createQuickPick(); + input.title = title; + input.step = step; + input.totalSteps = totalSteps; + input.ignoreFocusOut = ignoreFocusOut ?? false; + input.placeholder = placeholder; + input.items = items; + if (activeItem) { + input.activeItems = [activeItem]; + } + input.buttons = [ + ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), + ...(buttons || []), + ]; + disposables.push( + input.onDidTriggerButton((item) => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidChangeSelection((items) => resolve(items[0])) + // input.onDidHide(() => { + // (async () => { + // reject( + // shouldResume && (await shouldResume()) + // ? InputFlowAction.resume + // : InputFlowAction.cancel + // ); + // })().catch(reject); + // }) + ); + if (this.current) { + this.current.dispose(); + } + this.current = input; + this.current.show(); + }); + } finally { + disposables.forEach((d) => d.dispose()); + } + } + + async showInputBox

({ + title, + step, + totalSteps, + value, + prompt, + validate, + buttons, + ignoreFocusOut, + placeholder, + // shouldResume, + }: P) { + const disposables: Disposable[] = []; + try { + return await new Promise< + string | (P extends { buttons: (infer I)[] } ? I : never) + >((resolve, reject) => { + const input = window.createInputBox(); + input.title = title; + input.step = step; + input.totalSteps = totalSteps; + input.value = value || ""; + input.prompt = prompt; + input.ignoreFocusOut = ignoreFocusOut ?? false; + input.placeholder = placeholder; + input.buttons = [ + ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), + ...(buttons || []), + ]; + let validating = validate(""); + disposables.push( + input.onDidTriggerButton((item) => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidAccept(async () => { + const value = input.value; + input.enabled = false; + input.busy = true; + if (!(await validate(value))) { + resolve(value); + } + input.enabled = true; + input.busy = false; + }), + input.onDidChangeValue(async (text) => { + const current = validate(text); + validating = current; + const validationMessage = await current; + if (current === validating) { + input.validationMessage = validationMessage; + } + }) + // input.onDidHide(() => { + // (async () => { + // reject( + // shouldResume && (await shouldResume()) + // ? InputFlowAction.resume + // : InputFlowAction.cancel + // ); + // })().catch(reject); + // }) + ); + if (this.current) { + this.current.dispose(); + } + this.current = input; + this.current.show(); + }); + } finally { + disposables.forEach((d) => d.dispose()); + } + } +} diff --git a/packages/cloudflare-workers-bindings-extension/src/bindings.ts b/packages/cloudflare-workers-bindings-extension/src/bindings.ts index 817b39fdebbd..5e4c7f715daf 100644 --- a/packages/cloudflare-workers-bindings-extension/src/bindings.ts +++ b/packages/cloudflare-workers-bindings-extension/src/bindings.ts @@ -200,26 +200,29 @@ export class BindingsProvider implements vscode.TreeDataProvider { // Finds the first wrangler config file in the workspace and parse it export async function getWranglerConfig(): Promise { - const [configUri] = await vscode.workspace.findFiles( - "wrangler.{toml,jsonc,json}", - null, - 1 - ); - + const configUri = await getConfigUri(); if (!configUri) { return null; } - const workspaceFolder = vscode.workspace.getWorkspaceFolder(configUri); if (!workspaceFolder) { return null; } - const wrangler = await importWrangler(workspaceFolder.uri.fsPath); + const wrangler = importWrangler(workspaceFolder.uri.fsPath); const { rawConfig } = wrangler.experimental_readRawConfig({ config: configUri.fsPath, }); return rawConfig; } + +export async function getConfigUri(): Promise { + const [configUri] = await vscode.workspace.findFiles( + "wrangler.{toml,jsonc,json}", + null, + 1 + ); + return configUri; +} diff --git a/packages/cloudflare-workers-bindings-extension/src/extension.ts b/packages/cloudflare-workers-bindings-extension/src/extension.ts index 9da94758ee44..d91a7e71ac4b 100644 --- a/packages/cloudflare-workers-bindings-extension/src/extension.ts +++ b/packages/cloudflare-workers-bindings-extension/src/extension.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { multiStepInput } from "./add-binding"; import { BindingsProvider } from "./bindings"; export type Result = { @@ -31,7 +32,12 @@ export async function activate( "cloudflare-workers-bindings.refresh", () => bindingsProvider.refresh() ); - + vscode.commands.registerCommand( + "cloudflare-workers-bindings.addEntry", + async () => { + await multiStepInput(context); + } + ); // Cleanup when the extension is deactivated context.subscriptions.push(bindingsView, watcher, refreshCommand); diff --git a/packages/cloudflare-workers-bindings-extension/src/wrangler.ts b/packages/cloudflare-workers-bindings-extension/src/wrangler.ts index 2e35d08288e2..3f23a1f4e6a9 100644 --- a/packages/cloudflare-workers-bindings-extension/src/wrangler.ts +++ b/packages/cloudflare-workers-bindings-extension/src/wrangler.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import * as vscode from "vscode"; export function importWrangler( workspaceRoot: string