Skip to content
Open
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
5 changes: 3 additions & 2 deletions packages/eas-cli/src/credentials/ios/actions/DeviceUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ export async function chooseDevicesAsync(
): Promise<AppleDevice[]> {
const preselectedDeviceIdentifierSet = new Set(preselectedDeviceIdentifiers);
const isSelected = (device: AppleDeviceFragment): boolean =>
preselectedDeviceIdentifierSet.size === 0 ||
preselectedDeviceIdentifierSet.has(device.identifier);
const { devices } = await promptAsync({
type: 'multiselect',
name: 'devices',
selectionFormat: '<num> devices selected',
message: 'Select devices for the ad hoc build:',
hint: '- Space to select. Return to submit',
hint:
'- / search. Enter applies search; Enter again submits. Space toggles; a toggles visible',
searchable: true,
choices: allDevices.map(device => ({
value: device,
title: formatDeviceLabel(device),
Expand Down
250 changes: 249 additions & 1 deletion packages/eas-cli/src/easMultiselect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ const noop = (): void => {};

export type Question<T extends string = string> = PromptObject<T> & {
selectionFormat?: string;
searchable?: boolean;
};

type EasMultiselectChoice = Choice & {
description?: string;
disabled?: boolean;
selected?: boolean;
title?: string;
value?: unknown;
};

/**
Expand All @@ -20,13 +29,24 @@ export type Question<T extends string = string> = PromptObject<T> & {
*
* Short format is used when more than one option is selected.
*
* @param searchable
* Enables slash-prefixed text filtering. Press `/` to enter search, Enter to apply the
* current search, and Escape to clear it.
*
**/

export default class EasMultiselect extends MultiselectPrompt {
private searchQuery = '';
private searchInputActive = false;
private readonly searchable: boolean;

constructor(opts: Question) {
super(opts);
super({ ...opts, overrideRender: true });
this.selectionFormat = opts.selectionFormat;
this.searchable = opts.searchable ?? false;
this.render();
}

override renderDoneOrInstructions(): string {
if (this.done && this.selectionFormat && this.value) {
const selectedOptionsCount = this.value.filter(e => e.selected).length;
Expand All @@ -35,8 +55,236 @@ export default class EasMultiselect extends MultiselectPrompt {
return this.selectionFormat.replace('<num>', selectedOptionsCount.toString());
}
}
if (this.searchable && !this.done) {
return this.renderSearchablePromptStatus();
}
return super.renderDoneOrInstructions();
}

override renderOptions(_options: EasMultiselectChoice[]): string {
const visibleOptions = this.visibleOptions;
return super.renderOptions(visibleOptions);
}

override first(): void {
this.cursor = 0;
this.render();
}

override last(): void {
this.cursor = Math.max(this.visibleOptions.length - 1, 0);
this.render();
}

override next(): void {
const visibleOptionsCount = this.visibleOptions.length;
if (visibleOptionsCount === 0) {
return this.bell();
}
this.cursor = (this.cursor + 1) % visibleOptionsCount;
this.render();
}

override up(): void {
const visibleOptionsCount = this.visibleOptions.length;
if (visibleOptionsCount === 0) {
return this.bell();
}
this.cursor = this.cursor === 0 ? visibleOptionsCount - 1 : this.cursor - 1;
this.render();
}

override down(): void {
const visibleOptionsCount = this.visibleOptions.length;
if (visibleOptionsCount === 0) {
return this.bell();
}
this.cursor = this.cursor === visibleOptionsCount - 1 ? 0 : this.cursor + 1;
this.render();
}

override left(): void {
const choice = this.highlightedChoice;
if (!choice) {
return this.bell();
}
choice.selected = false;
this.render();
}

override right(): void {
const choice = this.highlightedChoice;
if (!choice || choice.disabled) {
return this.bell();
}
if (this.isAtMaxChoices) {
return this.bell();
}
choice.selected = true;
this.render();
}

override handleSpaceToggle(): void {
const choice = this.highlightedChoice;
if (!choice) {
return this.bell();
}
if (choice.selected) {
choice.selected = false;
this.render();
} else if (choice.disabled || this.isAtMaxChoices) {
return this.bell();
} else {
choice.selected = true;
this.render();
}
}

override toggleAll(): void {
if (this.maxChoices !== undefined) {
return this.bell();
}
const choice = this.highlightedChoice;
if (!choice || choice.disabled) {
return this.bell();
}
const newSelected = !choice.selected;
this.visibleOptions
.filter(option => !option.disabled)
.forEach(option => (option.selected = newSelected));
this.render();
}

override submit(): void {
if (this.searchable && this.searchInputActive) {
this.searchInputActive = false;
this.render();
return;
}
super.submit();
}

override exit(): void {
if (this.searchable && (this.searchInputActive || this.searchQuery)) {
this.clearSearch();
return;
}
super.exit();
}

delete(): void {
if (!this.searchable || (!this.searchInputActive && !this.searchQuery)) {
return this.bell();
}
if (this.searchInputActive && !this.searchQuery) {
this.searchInputActive = false;
this.render();
return;
}
this.searchInputActive = true;
this.setSearchQuery(this.searchQuery.slice(0, -1));
}

override _(c: string, key: unknown): void {
if (!this.searchable) {
super._(c, key);
return;
}
if (this.searchInputActive) {
this.setSearchQuery(`${this.searchQuery}${c}`);
return;
}
if (c === '/') {
this.searchQuery = '';
this.searchInputActive = true;
this.cursor = 0;
this.render();
return;
}
super._(c, key);
}

private get visibleOptions(): EasMultiselectChoice[] {
const query = normalizeSearchText(this.searchQuery);
if (!this.searchable || !query) {
return this.value;
}
return this.value.filter(choice => isSearchMatch(searchableChoiceText(choice), query));
}

private get highlightedChoice(): EasMultiselectChoice | undefined {
return this.visibleOptions[this.cursor];
}

private get isAtMaxChoices(): boolean {
return (
this.maxChoices !== undefined && this.value.filter(e => e.selected).length >= this.maxChoices
);
}

private setSearchQuery(query: string): void {
this.searchQuery = query;
const visibleOptionsCount = this.visibleOptions.length;
this.cursor = visibleOptionsCount === 0 ? 0 : Math.min(this.cursor, visibleOptionsCount - 1);
this.render();
}

private clearSearch(): void {
this.searchQuery = '';
this.searchInputActive = false;
this.cursor = 0;
this.render();
}

private renderSearchablePromptStatus(): string {
const parts = [this.hint];
if (this.searchInputActive || this.searchQuery) {
const filterStatus = `Filter: /${this.searchQuery}`;
const matchStatus = `${this.visibleOptions.length}/${this.value.length} matches`;
parts.push(this.searchInputActive ? filterStatus : `${filterStatus} (${matchStatus})`);
}
const choice = this.highlightedChoice;
if (choice?.disabled) {
parts.push(this.warn);
}
return parts.filter(Boolean).join(' ');
}
}

function searchableChoiceText(choice: EasMultiselectChoice): string {
return [choice.title, choice.description, valueToSearchableText(choice.value)]
.filter(Boolean)
.join(' ');
}

function valueToSearchableText(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (typeof value === 'object' && value !== null) {
try {
return JSON.stringify(value);
} catch {
return '';
}
}
return '';
}

function normalizeSearchText(value: string): string {
return value.toLowerCase().replace(/\s+/g, ' ').trim();
}

function compactSearchText(value: string): string {
return value.replace(/[^a-z0-9]/g, '');
}

function isSearchMatch(value: string, query: string): boolean {
const normalizedValue = normalizeSearchText(value);
return (
normalizedValue.includes(query) ||
compactSearchText(normalizedValue).includes(compactSearchText(query))
);
}

export const easMultiselect = (args: Question): Promise<Choice[]> => {
Expand Down
21 changes: 20 additions & 1 deletion ts-declarations/prompts/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
declare module 'prompts/lib/elements' {
class MultiselectPrompt {
cursor: number;
selectionFormat: string | undefined;
done: boolean | undefined;
value: any[] | undefined;
hint: string;
maxChoices: number | undefined;
value: any[];
warn: string;

constructor(parameters: any);
_(c: string, key: unknown): void;
bell(): void;
down(): void;
exit(): void;
first(): void;
handleSpaceToggle(): void;
last(): void;
left(): void;
next(): void;
on(event: string, callback: (args: any) => any): any;
render(): void;
renderDoneOrInstructions(): string;
renderOptions(options: any[]): string;
right(): void;
submit(): void;
toggleAll(): void;
up(): void;
}
}
Loading