Skip to content

Commit 4ab7ffc

Browse files
authored
feat: add "add binding" ui to vscode extension (#7591)
* add 'add binding' command * cleanup * changeset * pr feedback * fixup
1 parent 1c4988a commit 4ab7ffc

File tree

8 files changed

+379
-11
lines changed

8 files changed

+379
-11
lines changed

.changeset/slimy-dots-hope.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"cloudflare-workers-bindings-extension": minor
3+
---
4+
5+
feat: add ui to add a binding via the extension

packages/cloudflare-workers-bindings-extension/package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
"command": "cloudflare-workers-bindings.refresh",
3232
"title": "Cloudflare Workers: Refresh bindings",
3333
"icon": "$(refresh)"
34+
},
35+
{
36+
"command": "cloudflare-workers-bindings.addBinding",
37+
"title": "Cloudflare Workers: Add binding",
38+
"icon": "$(add)"
3439
}
3540
],
3641
"menus": {
@@ -39,6 +44,11 @@
3944
"command": "cloudflare-workers-bindings.refresh",
4045
"when": "view == cloudflare-workers-bindings",
4146
"group": "navigation"
47+
},
48+
{
49+
"command": "cloudflare-workers-bindings.addBinding",
50+
"when": "view == cloudflare-workers-bindings",
51+
"group": "navigation"
4252
}
4353
]
4454
},
@@ -64,7 +74,7 @@
6474
"viewsWelcome": [
6575
{
6676
"view": "cloudflare-workers-bindings",
67-
"contents": "Welcome to Cloudflare Workers! [Learn more](https://workers.cloudflare.com).\n[Refresh Bindings](command:cloudflare-workers-bindings.refresh)"
77+
"contents": "Welcome to Cloudflare Workers! [Learn more](https://workers.cloudflare.com).\n[Add a binding](command:cloudflare-workers-bindings.addBinding)"
6878
}
6979
]
7080
},
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import {
2+
Disposable,
3+
env,
4+
ExtensionContext,
5+
QuickInput,
6+
QuickInputButton,
7+
QuickInputButtons,
8+
QuickPickItem,
9+
Uri,
10+
window,
11+
workspace,
12+
} from "vscode";
13+
import { getConfigUri } from "./show-bindings";
14+
import { importWrangler } from "./wrangler";
15+
16+
class BindingType implements QuickPickItem {
17+
constructor(
18+
public label: string,
19+
public configKey?: string,
20+
public detail?: string,
21+
public iconPath?: Uri
22+
) {}
23+
}
24+
25+
export async function addBindingFlow(context: ExtensionContext) {
26+
const bindingTypes: BindingType[] = [
27+
new BindingType(
28+
"KV",
29+
"kv_namespaces",
30+
"Global, low-latency, key-value data storage",
31+
Uri.file(context.asAbsolutePath("resources/icons/kv.svg"))
32+
),
33+
new BindingType(
34+
"R2",
35+
"r2_buckets",
36+
"Object storage for all your data",
37+
Uri.file(context.asAbsolutePath("resources/icons/r2.svg"))
38+
),
39+
new BindingType(
40+
"D1",
41+
"d1_databases",
42+
"Serverless SQL databases",
43+
Uri.file(context.asAbsolutePath("resources/icons/d1.svg"))
44+
),
45+
];
46+
47+
interface State {
48+
title: string;
49+
step: number;
50+
totalSteps: number;
51+
bindingType: BindingType;
52+
name: string;
53+
runtime: QuickPickItem;
54+
id: string;
55+
}
56+
57+
async function collectInputs() {
58+
const state = {} as Partial<State>;
59+
await MultiStepInput.run((input) => pickBindingType(input, state));
60+
return state as State;
61+
}
62+
63+
const title = "Add binding";
64+
65+
async function pickBindingType(input: MultiStepInput, state: Partial<State>) {
66+
const pick = await input.showQuickPick({
67+
title,
68+
step: 1,
69+
totalSteps: 2,
70+
placeholder: "Choose a binding type",
71+
items: bindingTypes,
72+
activeItem:
73+
typeof state.bindingType !== "string" ? state.bindingType : undefined,
74+
});
75+
state.bindingType = pick as BindingType;
76+
return (input: MultiStepInput) => inputBindingName(input, state);
77+
}
78+
79+
async function inputBindingName(
80+
input: MultiStepInput,
81+
state: Partial<State>
82+
) {
83+
let name = await input.showInputBox({
84+
title,
85+
step: 2,
86+
totalSteps: 2,
87+
value: state.name || "",
88+
prompt: "Choose a binding name",
89+
validate: validateNameIsUnique,
90+
placeholder: `e.g. MY_BINDING`,
91+
});
92+
state.name = name;
93+
return () => addToConfig(state);
94+
}
95+
96+
async function addToConfig(state: Partial<State>) {
97+
const configUri = await getConfigUri();
98+
if (!configUri) {
99+
// for some reason, if we just throw an error it doesn't surface properly when triggered by the button in the welcome view
100+
window.showErrorMessage(
101+
"Unable to locate Wrangler configuration file — have you opened a project with a wrangler.json(c) or wrangler.toml file?",
102+
{}
103+
);
104+
return null;
105+
}
106+
const workspaceFolder = workspace.getWorkspaceFolder(configUri);
107+
108+
if (!workspaceFolder) {
109+
return null;
110+
}
111+
112+
const wrangler = importWrangler(workspaceFolder.uri.fsPath);
113+
114+
workspace.openTextDocument(configUri).then((doc) => {
115+
window.showTextDocument(doc);
116+
try {
117+
wrangler.experimental_patchConfig(configUri.path, {
118+
[state.bindingType?.configKey!]: [{ binding: state.name! }],
119+
});
120+
window.showInformationMessage(`Created binding '${state.name}'`);
121+
} catch {
122+
window.showErrorMessage(
123+
`Unable to directly add binding to config file. A snippet has been copied to clipboard - please paste this into your config file.`
124+
);
125+
126+
const patch = `[[${state.bindingType?.configKey!}]]
127+
binding = "${state.name}"
128+
`;
129+
130+
env.clipboard.writeText(patch);
131+
}
132+
});
133+
}
134+
135+
async function validateNameIsUnique(name: string) {
136+
// TODO: actually validate uniqueness
137+
return name === "SOME_KV_BINDING" ? "Name not unique" : undefined;
138+
}
139+
140+
await collectInputs();
141+
}
142+
143+
// -------------------------------------------------------
144+
// Helper code that wraps the API for the multi-step case.
145+
// -------------------------------------------------------
146+
147+
class InputFlowAction {
148+
static back = new InputFlowAction();
149+
static cancel = new InputFlowAction();
150+
static resume = new InputFlowAction();
151+
}
152+
153+
type InputStep = (input: MultiStepInput) => Thenable<InputStep | void>;
154+
155+
interface QuickPickParameters<T extends QuickPickItem> {
156+
title: string;
157+
step: number;
158+
totalSteps: number;
159+
items: T[];
160+
activeItem?: T;
161+
ignoreFocusOut?: boolean;
162+
placeholder: string;
163+
buttons?: QuickInputButton[];
164+
}
165+
166+
interface InputBoxParameters {
167+
title: string;
168+
step: number;
169+
totalSteps: number;
170+
value: string;
171+
prompt: string;
172+
validate: (value: string) => Promise<string | undefined>;
173+
buttons?: QuickInputButton[];
174+
ignoreFocusOut?: boolean;
175+
placeholder?: string;
176+
}
177+
178+
export class MultiStepInput {
179+
static async run<T>(start: InputStep) {
180+
const input = new MultiStepInput();
181+
return input.stepThrough(start);
182+
}
183+
184+
private current?: QuickInput;
185+
private steps: InputStep[] = [];
186+
187+
private async stepThrough<T>(start: InputStep) {
188+
let step: InputStep | void = start;
189+
while (step) {
190+
this.steps.push(step);
191+
if (this.current) {
192+
this.current.enabled = false;
193+
this.current.busy = true;
194+
}
195+
try {
196+
step = await step(this);
197+
} catch (err) {
198+
if (err === InputFlowAction.back) {
199+
this.steps.pop();
200+
step = this.steps.pop();
201+
} else if (err === InputFlowAction.resume) {
202+
step = this.steps.pop();
203+
} else if (err === InputFlowAction.cancel) {
204+
step = undefined;
205+
} else {
206+
throw err;
207+
}
208+
}
209+
}
210+
if (this.current) {
211+
this.current.dispose();
212+
}
213+
}
214+
215+
async showQuickPick<
216+
T extends QuickPickItem,
217+
P extends QuickPickParameters<T>,
218+
>({
219+
title,
220+
step,
221+
totalSteps,
222+
items,
223+
activeItem,
224+
ignoreFocusOut,
225+
placeholder,
226+
buttons,
227+
}: P) {
228+
const disposables: Disposable[] = [];
229+
try {
230+
return await new Promise<
231+
T | (P extends { buttons: (infer I)[] } ? I : never)
232+
>((resolve, reject) => {
233+
const input = window.createQuickPick<T>();
234+
input.title = title;
235+
input.step = step;
236+
input.totalSteps = totalSteps;
237+
input.ignoreFocusOut = ignoreFocusOut ?? false;
238+
input.placeholder = placeholder;
239+
input.items = items;
240+
if (activeItem) {
241+
input.activeItems = [activeItem];
242+
}
243+
input.buttons = [
244+
...(this.steps.length > 1 ? [QuickInputButtons.Back] : []),
245+
...(buttons || []),
246+
];
247+
disposables.push(
248+
input.onDidTriggerButton((item) => {
249+
if (item === QuickInputButtons.Back) {
250+
reject(InputFlowAction.back);
251+
} else {
252+
resolve(<any>item);
253+
}
254+
}),
255+
input.onDidChangeSelection((items) => resolve(items[0]))
256+
);
257+
if (this.current) {
258+
this.current.dispose();
259+
}
260+
this.current = input;
261+
this.current.show();
262+
});
263+
} finally {
264+
disposables.forEach((d) => d.dispose());
265+
}
266+
}
267+
268+
async showInputBox<P extends InputBoxParameters>({
269+
title,
270+
step,
271+
totalSteps,
272+
value,
273+
prompt,
274+
validate,
275+
buttons,
276+
ignoreFocusOut,
277+
placeholder,
278+
}: P) {
279+
const disposables: Disposable[] = [];
280+
try {
281+
return await new Promise<
282+
string | (P extends { buttons: (infer I)[] } ? I : never)
283+
>((resolve, reject) => {
284+
const input = window.createInputBox();
285+
input.title = title;
286+
input.step = step;
287+
input.totalSteps = totalSteps;
288+
input.value = value || "";
289+
input.prompt = prompt;
290+
input.ignoreFocusOut = ignoreFocusOut ?? false;
291+
input.placeholder = placeholder;
292+
input.buttons = [
293+
...(this.steps.length > 1 ? [QuickInputButtons.Back] : []),
294+
...(buttons || []),
295+
];
296+
let validating = validate("");
297+
disposables.push(
298+
input.onDidTriggerButton((item) => {
299+
if (item === QuickInputButtons.Back) {
300+
reject(InputFlowAction.back);
301+
} else {
302+
resolve(<any>item);
303+
}
304+
}),
305+
input.onDidAccept(async () => {
306+
const value = input.value;
307+
input.enabled = false;
308+
input.busy = true;
309+
if (!(await validate(value))) {
310+
resolve(value);
311+
}
312+
input.enabled = true;
313+
input.busy = false;
314+
}),
315+
input.onDidChangeValue(async (text) => {
316+
const current = validate(text);
317+
validating = current;
318+
const validationMessage = await current;
319+
if (current === validating) {
320+
input.validationMessage = validationMessage;
321+
}
322+
})
323+
);
324+
if (this.current) {
325+
this.current.dispose();
326+
}
327+
this.current = input;
328+
this.current.show();
329+
});
330+
} finally {
331+
disposables.forEach((d) => d.dispose());
332+
}
333+
}
334+
}

0 commit comments

Comments
 (0)