Skip to content
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
134 changes: 113 additions & 21 deletions packages/core/src/shared/ui/pickerPrompter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as vscode from 'vscode'
import * as nls from 'vscode-nls'

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

/**
* Creates a UI element that presents a list of items which allow selecting many item at same time. List of results
* will be JSON.stringify into a string. Use Json.parse() to recover all selected items. Due to limitation of current
* implementation, the result of this QuickPick will always be a string regardless of given type.
*
* If you wish to pre-select some item in the Multipick, you can add `picked: true` in the DataQuickPickItem
*
* @example
```typescript
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,
}
]
// in wizard
this.form.syncFlags.bindPrompter(() => {
return createMultiPick(syncFlagItems, {
title: 'Specify parameters for sync',
placeholder: 'Press enter to proceed with highlighted option',
buttons: createCommonButtons(samSyncUrl),
})
})
```
*
* @param items An array or a Promise for items.
* @param options Customizes the QuickPick and QuickPickPrompter.
* @returns A {@link QuickPickPrompter}. This can be used directly with the `prompt` method or can be fed into a Wizard.
*/
export function createMultiPick<T>(
items: ItemLoadTypes<T>,
options?: ExtendedQuickPickOptions<T>
): QuickPickPrompter<T> {
const picker = vscode.window.createQuickPick<DataQuickPickItem<T>>() as DataQuickPick<T>
const mergedOptions = { ...defaultQuickpickOptions, ...options }
assign(mergedOptions, picker)
picker.buttons = mergedOptions.buttons ?? []
picker.canSelectMany = true

const prompter = new QuickPickPrompter<T>(picker, mergedOptions)

prompter.loadItems(items, false, mergedOptions.recentlyUsed).catch((e) => {
getLogger().error('createQuickPick: loadItems failed: %s', (e as Error).message)
})

return prompter
}

export async function showQuickPick<T>(
items: ItemLoadTypes<T>,
options?: ExtendedQuickPickOptions<T>
Expand Down Expand Up @@ -186,6 +241,10 @@ export function createLabelQuickPick<T extends string>(

function acceptItems<T>(picker: DataQuickPick<T>, resolve: (items: DataQuickPickItem<T>[]) => void): void {
if (picker.selectedItems.length === 0) {
if (picker.canSelectMany) {
// Allow empty choice in multipick
resolve(Array.from(picker.selectedItems))
}
return
}

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

const recentlyUsedItem = mergedItems.find((item) => item.recentlyUsed)
if (recentlyUsedItem !== undefined) {
if (items.includes(recentlyUsedItem)) {
const prefix = recentlyUsedItem.description
const recent = recentlyUsedDescription === recentlyUsed ? `(${recentlyUsed})` : recentlyUsedDescription
recentlyUsedItem.description = prefix ? `${prefix} ${recent}` : recent
}

picker.items = mergedItems.sort((a, b) => {
// Always prioritize specified comparator if there's any
if (this.options.compare) {
return this.options.compare(a, b)
}
if (a === recentlyUsedItem) {
return -1
} else if (b === recentlyUsedItem) {
return 1
if (picker.canSelectMany) {
picker.items = mergedItems.sort(this.options.compare)
// if has recent select, apply previous selected items
picker.selectedItems = picker.items.filter((item) => item.recentlyUsed)
} else {
if (items.includes(recentlyUsedItem)) {
const prefix = recentlyUsedItem.description
const recent =
recentlyUsedDescription === recentlyUsed ? `(${recentlyUsed})` : recentlyUsedDescription
recentlyUsedItem.description = prefix ? `${prefix} ${recent}` : recent
}
return 0
})

picker.activeItems = [recentlyUsedItem]
picker.items = mergedItems.sort((a, b) => {
// Always prioritize specified comparator if there's any
if (this.options.compare) {
return this.options.compare(a, b)
}
if (a === recentlyUsedItem) {
return -1
} else if (b === recentlyUsedItem) {
return 1
}
return 0
})

picker.activeItems = [recentlyUsedItem]
}
} else {
picker.items = mergedItems.sort(this.options.compare)

if (picker.items.length === 0 && !this.busy) {
this.isShowingPlaceholder = true
picker.items = this.options.noItemsFoundItem !== undefined ? [this.options.noItemsFoundItem] : []
}

this.selectItems(...recent.filter((i) => !i.invalidSelection))
if (picker.canSelectMany) {
// if doesn't have recent select, apply selection from DataQuickPickItems.picked
picker.selectedItems = picker.items.filter((item) => item.picked)
} else {
this.selectItems(...recent.filter((i) => !i.invalidSelection))
}
}
}

Expand Down Expand Up @@ -493,6 +563,28 @@ export class QuickPickPrompter<T> extends Prompter<T> {
return choices
}

if (this.quickPick.canSelectMany) {
// return if control signal
if (choices.length !== 0 && isWizardControl(choices[0].data)) {
return choices[0].data
}
// reset before setting recent again
this.quickPick.items.map((item) => {
;(item.recentlyUsed = false),
// picked will only work on the first choice. Once choosen, remove the picked flag.
(item.picked = false)
})
// note this can be empty
return JSON.stringify(
await Promise.all(
choices.map(async (choice) => {
choice.recentlyUsed = true
return choice.data instanceof Function ? await choice.data() : choice.data
})
)
) as T
}
// else single pick
this._lastPicked = choices[0]
const result = choices[0].data

Expand Down
128 changes: 127 additions & 1 deletion packages/core/src/test/shared/ui/pickerPrompter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {
defaultQuickpickOptions,
QuickPickPrompter,
customUserInput,
createMultiPick,
} from '../../../shared/ui/pickerPrompter'
import { hasKey, isNonNullable } from '../../../shared/utilities/tsUtils'
import { WIZARD_BACK } from '../../../shared/wizards/wizard'
import { WIZARD_BACK, WIZARD_EXIT } from '../../../shared/wizards/wizard'
import { getTestWindow } from '../../shared/vscode/window'
import { TestQuickPick } from '../vscode/quickInput'

Expand Down Expand Up @@ -396,3 +397,128 @@ describe('FilterBoxQuickPickPrompter', function () {
assert.strictEqual(await loadAndPrompt(), testItems[0].data)
})
})

describe('MultiPick', function () {
const items: DataQuickPickItem<string>[] = [
{ label: 'item1', data: 'yes', picked: true },
{ label: 'item2', data: 'no' },
]
const itemsNoPicked: DataQuickPickItem<string>[] = [
{ label: 'item1', data: 'yes' },
{ label: 'item2', data: 'no' },
]
let picker: TestQuickPick<DataQuickPickItem<string>>
let testPrompter: QuickPickPrompter<string>

const prepareMultipick = async (itemsToLoad: DataQuickPickItem<string>[]) => {
picker = getTestWindow().createQuickPick() as typeof picker
picker.canSelectMany = true
testPrompter = new QuickPickPrompter(picker)
await testPrompter.loadItems(itemsToLoad)
}

it('can handle back button', async function () {
await prepareMultipick(items)
testPrompter.onDidShow(() => picker.pressButton(createBackButton()))
assert.strictEqual(await testPrompter.prompt(), WIZARD_BACK)
})

it('can handle exit button', async function () {
await prepareMultipick(items)
testPrompter.onDidShow(() => picker.dispose())
assert.strictEqual(await testPrompter.prompt(), WIZARD_EXIT)
})

it('applies picked options', async function () {
await prepareMultipick(items)
picker.onDidShow(async () => {
picker.acceptDefault()
})

picker.onDidChangeActive(async (items) => {
assert.strictEqual(items[0].picked, true)
assert.strictEqual(items[1].picked, false)
assert.strictEqual(items[0].data, 'yes')
assert.strictEqual(items[1].data, 'no')
})

const result = await testPrompter.prompt()
assert.deepStrictEqual(result, JSON.stringify(['yes']))
})

it('pick non should return empty array', async function () {
await prepareMultipick(itemsNoPicked)

picker.onDidShow(async () => {
picker.acceptDefault()
})

picker.onDidChangeActive(async (items) => {
assert.strictEqual(items[0].picked, false)
assert.strictEqual(items[1].picked, false)
assert.strictEqual(items[0].data, 'yes')
assert.strictEqual(items[1].data, 'no')
})

const result = await testPrompter.prompt()
assert.deepStrictEqual(result, JSON.stringify([]))
})

it('accept all should return array', async function () {
await prepareMultipick(itemsNoPicked)
picker.onDidShow(async () => {
await picker.untilReady()
picker.acceptItems(itemsNoPicked[0], itemsNoPicked[1])
})

const result = await testPrompter.prompt()
assert.deepStrictEqual(result, JSON.stringify(['yes', 'no']))
})

it('creates a new prompter with options', async function () {
const prompter = createMultiPick(items, { title: 'test' })
assert.strictEqual(prompter.quickPick.title, 'test')
})

it('creates a new prompter when given a promise for items', async function () {
let resolveItems!: (items: DataQuickPickItem<string>[]) => void
const itemsPromise = new Promise<DataQuickPickItem<string>[]>((resolve) => (resolveItems = resolve))
const prompter = createMultiPick(itemsPromise)
void prompter.prompt()
assert.strictEqual(prompter.quickPick.busy, true)

resolveItems(items)
await itemsPromise

assert.strictEqual(prompter.quickPick.busy, false)
assert.deepStrictEqual(prompter.quickPick.items, items)
})

it('creates a new prompter when given an AsyncIterable', async function () {
let r1!: (v?: any) => void
let r2!: (v?: any) => void
const p1 = new Promise((r) => (r1 = r))
const p2 = new Promise((r) => (r2 = r))

async function* generator() {
for (const item of items) {
if (item === items[0]) {
await p1
} else {
await p2
}
yield [item]
}
}

const prompter = createMultiPick(generator())
r1()
await new Promise((r) => setImmediate(r))
assert.deepStrictEqual(prompter.quickPick.items, [items[0]])
assert.strictEqual(prompter.quickPick.busy, true)
r2()
await new Promise((r) => setImmediate(r))
assert.deepStrictEqual(prompter.quickPick.items, items)
assert.strictEqual(prompter.quickPick.busy, false)
})
})
9 changes: 9 additions & 0 deletions packages/core/src/test/shared/vscode/quickInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,15 @@ export class PickerTester<T extends vscode.QuickPickItem> {
this.triggers.onDidAccept.fire()
}

/**
* Attempts to accept the default state. Used to test Multipick
*
* See {@link acceptItem}.
*/
public acceptDefault(): void {
this.triggers.onDidAccept.fire()
}

/**
* Asserts that the given items are loaded in the QuickPick, in the given order.
*
Expand Down
Loading