Skip to content

Commit d2bc2d6

Browse files
authored
feat(ui): multipick prompter #5815
## Problem Currently prompter doesn't support multipick ## Solution Add a new function `createMultiPick` that supports multipick and can be used in Wizard. The returned result will be a list encoded by `JSON.stringify` , use `JSON.parse` to recover the list ## Proposed UX ![image](https://github.com/user-attachments/assets/fbf8516c-a572-4d1a-aa83-a69bb966903a) ```typescript // define DataQuickPickItem, note you can use `picked: true` to pre-select items in the multipick const syncFlagItems: DataQuickPickItem<string>[] = [ { label: 'Build in source', data: '--build-in-source', description: 'Opts in to build project in the source folder. Only for node apps', }, { label: 'Code', data: '--code', description: 'Sync only code resources (Lambda Functions, API Gateway, Step Functions)', picked: true, } ] export interface SyncParams { readonly syncFlags: string } // define the wizard export class SyncWizard extends Wizard<SyncParams> { public constructor() { super() this.form.syncFlags.bindPrompter(() => { return createMultiPick(syncFlagItems, { title: 'Specify parameters for sync', placeholder: 'Press enter to proceed with highlighted option', buttons: createCommonButtons(samSyncUrl), }) }) } } // run the wizard result = await new SyncWizard().run() // Use `JSON.parse` to decode to list -> ['--build-in-source','--code'] console.log(JSON.parse(result.syncFlags)) ```
1 parent 5f816f1 commit d2bc2d6

File tree

3 files changed

+249
-22
lines changed

3 files changed

+249
-22
lines changed

packages/core/src/shared/ui/pickerPrompter.ts

Lines changed: 113 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as vscode from 'vscode'
77
import * as nls from 'vscode-nls'
88

9-
import { isValidResponse, StepEstimator, WIZARD_BACK, WIZARD_EXIT } from '../wizards/wizard'
9+
import { isValidResponse, isWizardControl, StepEstimator, WIZARD_BACK, WIZARD_EXIT } from '../wizards/wizard'
1010
import { QuickInputButton, PrompterButtons } from './buttons'
1111
import { Prompter, PromptResult, Transform } from './prompter'
1212
import { assign, isAsyncIterable } from '../utilities/collectionUtils'
@@ -155,6 +155,61 @@ export function createQuickPick<T>(
155155
return prompter
156156
}
157157

158+
/**
159+
* Creates a UI element that presents a list of items which allow selecting many item at same time. List of results
160+
* will be JSON.stringify into a string. Use Json.parse() to recover all selected items. Due to limitation of current
161+
* implementation, the result of this QuickPick will always be a string regardless of given type.
162+
*
163+
* If you wish to pre-select some item in the Multipick, you can add `picked: true` in the DataQuickPickItem
164+
*
165+
* @example
166+
```typescript
167+
const syncFlagItems: DataQuickPickItem<string>[] = [
168+
{
169+
label: 'Build in source',
170+
data: '--build-in-source',
171+
description: 'Opts in to build project in the source folder. Only for node apps',
172+
},
173+
{
174+
label: 'Code',
175+
data: '--code',
176+
description: 'Sync only code resources (Lambda Functions, API Gateway, Step Functions)',
177+
picked: true,
178+
}
179+
]
180+
// in wizard
181+
this.form.syncFlags.bindPrompter(() => {
182+
return createMultiPick(syncFlagItems, {
183+
title: 'Specify parameters for sync',
184+
placeholder: 'Press enter to proceed with highlighted option',
185+
buttons: createCommonButtons(samSyncUrl),
186+
})
187+
})
188+
```
189+
*
190+
* @param items An array or a Promise for items.
191+
* @param options Customizes the QuickPick and QuickPickPrompter.
192+
* @returns A {@link QuickPickPrompter}. This can be used directly with the `prompt` method or can be fed into a Wizard.
193+
*/
194+
export function createMultiPick<T>(
195+
items: ItemLoadTypes<T>,
196+
options?: ExtendedQuickPickOptions<T>
197+
): QuickPickPrompter<T> {
198+
const picker = vscode.window.createQuickPick<DataQuickPickItem<T>>() as DataQuickPick<T>
199+
const mergedOptions = { ...defaultQuickpickOptions, ...options }
200+
assign(mergedOptions, picker)
201+
picker.buttons = mergedOptions.buttons ?? []
202+
picker.canSelectMany = true
203+
204+
const prompter = new QuickPickPrompter<T>(picker, mergedOptions)
205+
206+
prompter.loadItems(items, false, mergedOptions.recentlyUsed).catch((e) => {
207+
getLogger().error('createQuickPick: loadItems failed: %s', (e as Error).message)
208+
})
209+
210+
return prompter
211+
}
212+
158213
export async function showQuickPick<T>(
159214
items: ItemLoadTypes<T>,
160215
options?: ExtendedQuickPickOptions<T>
@@ -186,6 +241,10 @@ export function createLabelQuickPick<T extends string>(
186241

187242
function acceptItems<T>(picker: DataQuickPick<T>, resolve: (items: DataQuickPickItem<T>[]) => void): void {
188243
if (picker.selectedItems.length === 0) {
244+
if (picker.canSelectMany) {
245+
// Allow empty choice in multipick
246+
resolve(Array.from(picker.selectedItems))
247+
}
189248
return
190249
}
191250

@@ -354,35 +413,46 @@ export class QuickPickPrompter<T> extends Prompter<T> {
354413

355414
const recentlyUsedItem = mergedItems.find((item) => item.recentlyUsed)
356415
if (recentlyUsedItem !== undefined) {
357-
if (items.includes(recentlyUsedItem)) {
358-
const prefix = recentlyUsedItem.description
359-
const recent = recentlyUsedDescription === recentlyUsed ? `(${recentlyUsed})` : recentlyUsedDescription
360-
recentlyUsedItem.description = prefix ? `${prefix} ${recent}` : recent
361-
}
362-
363-
picker.items = mergedItems.sort((a, b) => {
364-
// Always prioritize specified comparator if there's any
365-
if (this.options.compare) {
366-
return this.options.compare(a, b)
367-
}
368-
if (a === recentlyUsedItem) {
369-
return -1
370-
} else if (b === recentlyUsedItem) {
371-
return 1
416+
if (picker.canSelectMany) {
417+
picker.items = mergedItems.sort(this.options.compare)
418+
// if has recent select, apply previous selected items
419+
picker.selectedItems = picker.items.filter((item) => item.recentlyUsed)
420+
} else {
421+
if (items.includes(recentlyUsedItem)) {
422+
const prefix = recentlyUsedItem.description
423+
const recent =
424+
recentlyUsedDescription === recentlyUsed ? `(${recentlyUsed})` : recentlyUsedDescription
425+
recentlyUsedItem.description = prefix ? `${prefix} ${recent}` : recent
372426
}
373-
return 0
374-
})
375427

376-
picker.activeItems = [recentlyUsedItem]
428+
picker.items = mergedItems.sort((a, b) => {
429+
// Always prioritize specified comparator if there's any
430+
if (this.options.compare) {
431+
return this.options.compare(a, b)
432+
}
433+
if (a === recentlyUsedItem) {
434+
return -1
435+
} else if (b === recentlyUsedItem) {
436+
return 1
437+
}
438+
return 0
439+
})
440+
441+
picker.activeItems = [recentlyUsedItem]
442+
}
377443
} else {
378444
picker.items = mergedItems.sort(this.options.compare)
379445

380446
if (picker.items.length === 0 && !this.busy) {
381447
this.isShowingPlaceholder = true
382448
picker.items = this.options.noItemsFoundItem !== undefined ? [this.options.noItemsFoundItem] : []
383449
}
384-
385-
this.selectItems(...recent.filter((i) => !i.invalidSelection))
450+
if (picker.canSelectMany) {
451+
// if doesn't have recent select, apply selection from DataQuickPickItems.picked
452+
picker.selectedItems = picker.items.filter((item) => item.picked)
453+
} else {
454+
this.selectItems(...recent.filter((i) => !i.invalidSelection))
455+
}
386456
}
387457
}
388458

@@ -493,6 +563,28 @@ export class QuickPickPrompter<T> extends Prompter<T> {
493563
return choices
494564
}
495565

566+
if (this.quickPick.canSelectMany) {
567+
// return if control signal
568+
if (choices.length !== 0 && isWizardControl(choices[0].data)) {
569+
return choices[0].data
570+
}
571+
// reset before setting recent again
572+
this.quickPick.items.map((item) => {
573+
;(item.recentlyUsed = false),
574+
// picked will only work on the first choice. Once choosen, remove the picked flag.
575+
(item.picked = false)
576+
})
577+
// note this can be empty
578+
return JSON.stringify(
579+
await Promise.all(
580+
choices.map(async (choice) => {
581+
choice.recentlyUsed = true
582+
return choice.data instanceof Function ? await choice.data() : choice.data
583+
})
584+
)
585+
) as T
586+
}
587+
// else single pick
496588
this._lastPicked = choices[0]
497589
const result = choices[0].data
498590

packages/core/src/test/shared/ui/pickerPrompter.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import {
1414
defaultQuickpickOptions,
1515
QuickPickPrompter,
1616
customUserInput,
17+
createMultiPick,
1718
} from '../../../shared/ui/pickerPrompter'
1819
import { hasKey, isNonNullable } from '../../../shared/utilities/tsUtils'
19-
import { WIZARD_BACK } from '../../../shared/wizards/wizard'
20+
import { WIZARD_BACK, WIZARD_EXIT } from '../../../shared/wizards/wizard'
2021
import { getTestWindow } from '../../shared/vscode/window'
2122
import { TestQuickPick } from '../vscode/quickInput'
2223

@@ -396,3 +397,128 @@ describe('FilterBoxQuickPickPrompter', function () {
396397
assert.strictEqual(await loadAndPrompt(), testItems[0].data)
397398
})
398399
})
400+
401+
describe('MultiPick', function () {
402+
const items: DataQuickPickItem<string>[] = [
403+
{ label: 'item1', data: 'yes', picked: true },
404+
{ label: 'item2', data: 'no' },
405+
]
406+
const itemsNoPicked: DataQuickPickItem<string>[] = [
407+
{ label: 'item1', data: 'yes' },
408+
{ label: 'item2', data: 'no' },
409+
]
410+
let picker: TestQuickPick<DataQuickPickItem<string>>
411+
let testPrompter: QuickPickPrompter<string>
412+
413+
const prepareMultipick = async (itemsToLoad: DataQuickPickItem<string>[]) => {
414+
picker = getTestWindow().createQuickPick() as typeof picker
415+
picker.canSelectMany = true
416+
testPrompter = new QuickPickPrompter(picker)
417+
await testPrompter.loadItems(itemsToLoad)
418+
}
419+
420+
it('can handle back button', async function () {
421+
await prepareMultipick(items)
422+
testPrompter.onDidShow(() => picker.pressButton(createBackButton()))
423+
assert.strictEqual(await testPrompter.prompt(), WIZARD_BACK)
424+
})
425+
426+
it('can handle exit button', async function () {
427+
await prepareMultipick(items)
428+
testPrompter.onDidShow(() => picker.dispose())
429+
assert.strictEqual(await testPrompter.prompt(), WIZARD_EXIT)
430+
})
431+
432+
it('applies picked options', async function () {
433+
await prepareMultipick(items)
434+
picker.onDidShow(async () => {
435+
picker.acceptDefault()
436+
})
437+
438+
picker.onDidChangeActive(async (items) => {
439+
assert.strictEqual(items[0].picked, true)
440+
assert.strictEqual(items[1].picked, false)
441+
assert.strictEqual(items[0].data, 'yes')
442+
assert.strictEqual(items[1].data, 'no')
443+
})
444+
445+
const result = await testPrompter.prompt()
446+
assert.deepStrictEqual(result, JSON.stringify(['yes']))
447+
})
448+
449+
it('pick non should return empty array', async function () {
450+
await prepareMultipick(itemsNoPicked)
451+
452+
picker.onDidShow(async () => {
453+
picker.acceptDefault()
454+
})
455+
456+
picker.onDidChangeActive(async (items) => {
457+
assert.strictEqual(items[0].picked, false)
458+
assert.strictEqual(items[1].picked, false)
459+
assert.strictEqual(items[0].data, 'yes')
460+
assert.strictEqual(items[1].data, 'no')
461+
})
462+
463+
const result = await testPrompter.prompt()
464+
assert.deepStrictEqual(result, JSON.stringify([]))
465+
})
466+
467+
it('accept all should return array', async function () {
468+
await prepareMultipick(itemsNoPicked)
469+
picker.onDidShow(async () => {
470+
await picker.untilReady()
471+
picker.acceptItems(itemsNoPicked[0], itemsNoPicked[1])
472+
})
473+
474+
const result = await testPrompter.prompt()
475+
assert.deepStrictEqual(result, JSON.stringify(['yes', 'no']))
476+
})
477+
478+
it('creates a new prompter with options', async function () {
479+
const prompter = createMultiPick(items, { title: 'test' })
480+
assert.strictEqual(prompter.quickPick.title, 'test')
481+
})
482+
483+
it('creates a new prompter when given a promise for items', async function () {
484+
let resolveItems!: (items: DataQuickPickItem<string>[]) => void
485+
const itemsPromise = new Promise<DataQuickPickItem<string>[]>((resolve) => (resolveItems = resolve))
486+
const prompter = createMultiPick(itemsPromise)
487+
void prompter.prompt()
488+
assert.strictEqual(prompter.quickPick.busy, true)
489+
490+
resolveItems(items)
491+
await itemsPromise
492+
493+
assert.strictEqual(prompter.quickPick.busy, false)
494+
assert.deepStrictEqual(prompter.quickPick.items, items)
495+
})
496+
497+
it('creates a new prompter when given an AsyncIterable', async function () {
498+
let r1!: (v?: any) => void
499+
let r2!: (v?: any) => void
500+
const p1 = new Promise((r) => (r1 = r))
501+
const p2 = new Promise((r) => (r2 = r))
502+
503+
async function* generator() {
504+
for (const item of items) {
505+
if (item === items[0]) {
506+
await p1
507+
} else {
508+
await p2
509+
}
510+
yield [item]
511+
}
512+
}
513+
514+
const prompter = createMultiPick(generator())
515+
r1()
516+
await new Promise((r) => setImmediate(r))
517+
assert.deepStrictEqual(prompter.quickPick.items, [items[0]])
518+
assert.strictEqual(prompter.quickPick.busy, true)
519+
r2()
520+
await new Promise((r) => setImmediate(r))
521+
assert.deepStrictEqual(prompter.quickPick.items, items)
522+
assert.strictEqual(prompter.quickPick.busy, false)
523+
})
524+
})

packages/core/src/test/shared/vscode/quickInput.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,15 @@ export class PickerTester<T extends vscode.QuickPickItem> {
225225
this.triggers.onDidAccept.fire()
226226
}
227227

228+
/**
229+
* Attempts to accept the default state. Used to test Multipick
230+
*
231+
* See {@link acceptItem}.
232+
*/
233+
public acceptDefault(): void {
234+
this.triggers.onDidAccept.fire()
235+
}
236+
228237
/**
229238
* Asserts that the given items are loaded in the QuickPick, in the given order.
230239
*

0 commit comments

Comments
 (0)