Skip to content

Commit

Permalink
feat: add "add binding" ui to vscode extension (#7591)
Browse files Browse the repository at this point in the history
* add 'add binding' command

* cleanup

* changeset

* pr feedback

* fixup
  • Loading branch information
emily-shen authored Dec 19, 2024
1 parent 1c4988a commit 4ab7ffc
Show file tree
Hide file tree
Showing 8 changed files with 379 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/slimy-dots-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cloudflare-workers-bindings-extension": minor
---

feat: add ui to add a binding via the extension
12 changes: 11 additions & 1 deletion packages/cloudflare-workers-bindings-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
"command": "cloudflare-workers-bindings.refresh",
"title": "Cloudflare Workers: Refresh bindings",
"icon": "$(refresh)"
},
{
"command": "cloudflare-workers-bindings.addBinding",
"title": "Cloudflare Workers: Add binding",
"icon": "$(add)"
}
],
"menus": {
Expand All @@ -39,6 +44,11 @@
"command": "cloudflare-workers-bindings.refresh",
"when": "view == cloudflare-workers-bindings",
"group": "navigation"
},
{
"command": "cloudflare-workers-bindings.addBinding",
"when": "view == cloudflare-workers-bindings",
"group": "navigation"
}
]
},
Expand All @@ -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.addBinding)"
}
]
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
334 changes: 334 additions & 0 deletions packages/cloudflare-workers-bindings-extension/src/add-binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import {
Disposable,
env,
ExtensionContext,
QuickInput,
QuickInputButton,
QuickInputButtons,
QuickPickItem,
Uri,
window,
workspace,
} from "vscode";
import { getConfigUri } from "./show-bindings";
import { importWrangler } from "./wrangler";

class BindingType implements QuickPickItem {
constructor(
public label: string,
public configKey?: string,
public detail?: string,
public iconPath?: Uri
) {}
}

export async function addBindingFlow(context: ExtensionContext) {
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<State>;
await MultiStepInput.run((input) => pickBindingType(input, state));
return state as State;
}

const title = "Add binding";

async function pickBindingType(input: MultiStepInput, state: Partial<State>) {
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,
});
state.bindingType = pick as BindingType;
return (input: MultiStepInput) => inputBindingName(input, state);
}

async function inputBindingName(
input: MultiStepInput,
state: Partial<State>
) {
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`,
});
state.name = name;
return () => addToConfig(state);
}

async function addToConfig(state: Partial<State>) {
const configUri = await getConfigUri();
if (!configUri) {
// for some reason, if we just throw an error it doesn't surface properly when triggered by the button in the welcome view
window.showErrorMessage(
"Unable to locate Wrangler configuration file — have you opened a project with a wrangler.json(c) or wrangler.toml file?",
{}
);
return null;
}
const workspaceFolder = workspace.getWorkspaceFolder(configUri);

if (!workspaceFolder) {
return null;
}

const wrangler = importWrangler(workspaceFolder.uri.fsPath);

workspace.openTextDocument(configUri).then((doc) => {
window.showTextDocument(doc);
try {
wrangler.experimental_patchConfig(configUri.path, {
[state.bindingType?.configKey!]: [{ 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?.configKey!}]]
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;
}

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<InputStep | void>;

interface QuickPickParameters<T extends QuickPickItem> {
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<string | undefined>;
buttons?: QuickInputButton[];
ignoreFocusOut?: boolean;
placeholder?: string;
}

export class MultiStepInput {
static async run<T>(start: InputStep) {
const input = new MultiStepInput();
return input.stepThrough(start);
}

private current?: QuickInput;
private steps: InputStep[] = [];

private async stepThrough<T>(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<T>,
>({
title,
step,
totalSteps,
items,
activeItem,
ignoreFocusOut,
placeholder,
buttons,
}: P) {
const disposables: Disposable[] = [];
try {
return await new Promise<
T | (P extends { buttons: (infer I)[] } ? I : never)
>((resolve, reject) => {
const input = window.createQuickPick<T>();
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(<any>item);
}
}),
input.onDidChangeSelection((items) => resolve(items[0]))
);
if (this.current) {
this.current.dispose();
}
this.current = input;
this.current.show();
});
} finally {
disposables.forEach((d) => d.dispose());
}
}

async showInputBox<P extends InputBoxParameters>({
title,
step,
totalSteps,
value,
prompt,
validate,
buttons,
ignoreFocusOut,
placeholder,
}: 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(<any>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;
}
})
);
if (this.current) {
this.current.dispose();
}
this.current = input;
this.current.show();
});
} finally {
disposables.forEach((d) => d.dispose());
}
}
}
Loading

0 comments on commit 4ab7ffc

Please sign in to comment.