diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ce83777..21b4b383 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export { CANCEL_SYMBOL, getColumns, getRows, + isAsync, isCancel, wrapTextWithPrefix, } from './utils/index.js'; diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 68091dff..4417249b 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -1,6 +1,7 @@ import type { Key } from 'node:readline'; import { styleText } from 'node:util'; import { findCursor } from '../utils/cursor.js'; +import { isAsync } from '../utils/index.js'; import Prompt, { type PromptOptions } from './prompt.js'; interface OptionLike { @@ -46,10 +47,29 @@ function normalisedValue(multiple: boolean, values: T[] | undefined): T | T[] return values[0]; } -export interface AutocompleteOptions - extends PromptOptions> { +function isAsyncOptions( + options: AutocompleteOptions['options'] +): options is AutocompleteOptionsAsync['options'] { + return isAsync({ options }, 'options'); +} + +interface AutocompleteOptionsSync { options: T[] | ((this: AutocompletePrompt) => T[]); filter?: FilterFunction; +} + +interface AutocompleteOptionsAsync { + options: (this: AutocompletePrompt, signal?: AbortSignal) => Promise; + interval: number; + frameCount: number; + debounce?: number; + filter?: FilterFunction; +} + +export type AutocompleteOptions = PromptOptions< + T['value'] | T['value'][], + AutocompletePrompt +> & { multiple?: boolean; /** * When set (non-empty), pressing Tab with no input fills the field with this value @@ -63,21 +83,33 @@ export interface AutocompleteOptions * currently focused option's value, as if the user had typed it. */ completeOnTab?: boolean; -} +} & (AutocompleteOptionsSync | AutocompleteOptionsAsync); export default class AutocompletePrompt extends Prompt< T['value'] | T['value'][] > { - filteredOptions: T[]; + options: T[] = []; + filteredOptions: T[] = []; multiple: boolean; isNavigating = false; selectedValues: Array = []; focusedValue: T['value'] | undefined; + + isLoading = false; + spinnerIndex = 0; + #spinnerFrameCount: number = 0; + #spinnerInterval?: number; + #spinnerTimer?: NodeJS.Timeout; + + #debounceMs?: number; + #debounceTimer?: NodeJS.Timeout; + #abortController?: AbortController; + #cursor = 0; #lastUserInput = ''; #filterFn: FilterFunction | undefined; - #options: T[] | (() => T[]); + #options: AutocompleteOptions['options']; #placeholder: string | undefined; #completeOnTab: boolean; @@ -98,51 +130,56 @@ export default class AutocompletePrompt extends Prompt< return `${preCursor}${styleText('inverse', cursorChar)}${rest}`; } - get options(): T[] { - if (typeof this.#options === 'function') { - return this.#options(); - } - return this.#options; - } - constructor(opts: AutocompleteOptions) { super(opts); this.#options = opts.options; this.#placeholder = opts.placeholder; this.#completeOnTab = opts.completeOnTab === true; - const options = this.options; - this.filteredOptions = [...options]; this.multiple = opts.multiple === true; this.#filterFn = typeof opts.options === 'function' ? opts.filter : (opts.filter ?? defaultFilter); - let initialValues: unknown[] | undefined; - if (opts.initialValue && Array.isArray(opts.initialValue)) { - if (this.multiple) { - initialValues = opts.initialValue; + + if (isAsync, AutocompleteOptionsAsync>(opts, 'options')) { + this.#debounceMs = opts.debounce ?? 300; + this.#spinnerInterval = opts.interval; + this.#spinnerFrameCount = opts.frameCount; + } + + const optionsProcessing = () => { + this.filteredOptions = [...this.options]; + + let initialValues: unknown[] | undefined; + if (opts.initialValue && Array.isArray(opts.initialValue)) { + if (this.multiple) { + initialValues = opts.initialValue; + } else { + initialValues = opts.initialValue.slice(0, 1); + } } else { - initialValues = opts.initialValue.slice(0, 1); - } - } else { - if (!this.multiple && this.options.length > 0) { - initialValues = [this.options[0]?.value]; + if (!this.multiple && this.options.length > 0) { + initialValues = [this.options[0]?.value]; + } } - } - if (initialValues) { - for (const selectedValue of initialValues) { - const selectedIndex = options.findIndex((opt) => opt.value === selectedValue); - if (selectedIndex !== -1) { - this.toggleSelected(selectedValue); - this.#cursor = selectedIndex; + if (initialValues) { + for (const selectedValue of initialValues) { + const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue); + if (selectedIndex !== -1) { + this.toggleSelected(selectedValue); + this.#cursor = selectedIndex; + } } } - } - this.focusedValue = this.options[this.#cursor]?.value; + this.focusedValue = this.options[this.#cursor]?.value; + }; + + this.#populateOptions(optionsProcessing); this.on('key', (char, key) => this.#onKey(char, key)); this.on('userInput', (value) => this.#onUserInputChanged(value)); + this.on('finalize', () => this.#setLoading(false)); } protected override _isActionKey(char: string | undefined, key: Key): boolean { @@ -156,6 +193,67 @@ export default class AutocompletePrompt extends Prompt< ); } + async #populateOptions(runnable?: () => void) { + if (isAsyncOptions(this.#options)) { + this.#setLoading(true); + + // Allows aborting the previous async options call if the user + // made a new search before getting results from the previous one. + if (this.#abortController) { + this.#abortController.abort(); + } + + const abortController = new AbortController(); + this.#abortController = abortController; + const options = await this.#options(abortController.signal); + + // Don't do anything else if there was a new search done. + // This works because abortController will still have the old instance + if (this.#abortController !== abortController) { + return; + } + + this.options = options; + this.#setLoading(false); + this.#abortController = undefined; + + runnable?.(); + this.render(); + return; + } + + if (typeof this.#options === 'function') { + this.options = this.#options(); + } else { + this.options = this.#options; + } + + runnable?.(); + } + + #setLoading(loading: boolean) { + this.isLoading = loading; + + if (!loading) { + if (this.#spinnerTimer) { + clearInterval(this.#spinnerTimer); + this.#spinnerTimer = undefined; + } + return; + } + + if (!this.#spinnerTimer) { + this.spinnerIndex = 0; + + this.#spinnerTimer = setInterval(() => { + this.spinnerIndex = (this.spinnerIndex + 1) % this.#spinnerFrameCount; + this.render(); + }, this.#spinnerInterval); + + this.render(); + } + } + #onKey(_char: string | undefined, key: Key): void { const isUpKey = key.name === 'up'; const isDownKey = key.name === 'down'; @@ -233,28 +331,41 @@ export default class AutocompletePrompt extends Prompt< return; } - if (this.multiple) { - if (this.selectedValues.includes(value)) { - this.selectedValues = this.selectedValues.filter((v) => v !== value); - } else { - this.selectedValues = [...this.selectedValues, value]; - } - } else { + if (!this.multiple) { this.selectedValues = [value]; + return; + } + + if (this.selectedValues.includes(value)) { + this.selectedValues = this.selectedValues.filter((v) => v !== value); + } else { + this.selectedValues = [...this.selectedValues, value]; } } #onUserInputChanged(value: string): void { - if (value !== this.#lastUserInput) { - this.#lastUserInput = value; + if (value === this.#lastUserInput) { + return; + } - const options = this.options; + this.#lastUserInput = value; + if (isAsyncOptions(this.#options)) { + this.#handleInputChangedAsync(value); + } else { + this.#handleInputChangedSync(value); + } + } + + #handleInputChangedSync(value: string) { + const optionsProcessing = () => { if (value && this.#filterFn) { - this.filteredOptions = options.filter((opt) => this.#filterFn?.(value, opt)); + const filterFn = this.#filterFn; + this.filteredOptions = this.options.filter((opt) => filterFn(value, opt)); } else { - this.filteredOptions = [...options]; + this.filteredOptions = [...this.options]; } + const valueCursor = getCursorForValue(this.focusedValue, this.filteredOptions); this.#cursor = findCursor(valueCursor, 0, this.filteredOptions); const focusedOption = this.filteredOptions[this.#cursor]; @@ -270,6 +381,19 @@ export default class AutocompletePrompt extends Prompt< this.deselectAll(); } } + }; + + this.#populateOptions(optionsProcessing); + } + + #handleInputChangedAsync(value: string) { + // Clear previous debounce timer + if (this.#debounceTimer) { + clearTimeout(this.#debounceTimer); } + + this.#debounceTimer = setTimeout(() => { + this.#handleInputChangedSync(value); + }, this.#debounceMs); } } diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 16830864..cc873699 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -296,7 +296,7 @@ export default class Prompt { this.output.write(cursor.move(-999, lines * -1)); } - private render() { + protected render() { const frame = wrapAnsi(this._render(this) ?? '', process.stdout.columns, { hard: true, trim: false, diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 96199a8b..f89cbd2a 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -125,3 +125,15 @@ export function wrapTextWithPrefix( .join('\n'); return lines; } + +/** + * Check if a specific prop is a async function + */ +export function isAsync( + opts: Sync | Async, + prop: string +): opts is Async { + const value = (opts as Record)[prop]; + + return typeof value === 'function' && value.constructor.name === 'AsyncFunction'; +} diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index af6d76a2..e92e5de6 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -1,14 +1,16 @@ import { styleText } from 'node:util'; import type { Validate } from '@clack/core'; -import { AutocompletePrompt, settings } from '@clack/core'; +import { AutocompletePrompt, isAsync, settings } from '@clack/core'; import { type CommonOptions, + N_INTERVAL, S_BAR, S_BAR_END, S_CHECKBOX_INACTIVE, S_CHECKBOX_SELECTED, S_RADIO_ACTIVE, S_RADIO_INACTIVE, + S_SPINNER, symbol, } from './common.js'; import { limitOptions } from './limit-options.js'; @@ -42,23 +44,31 @@ function getSelectedOptions(values: T[], options: Option[]): Option[] { return results; } +function getAsyncFilter( + opts: AutocompleteOptions +): AutocompleteSharedOptionsAsync['filter'] { + // filter not provided at all + if (!('filter' in opts)) return; + + if (opts.filter) { + return opts.filter; + } + + // filter undefined + return (search: string, opt: Option) => { + return getFilteredOption(search, opt); + }; +} + /** * Options for the {@link autocomplete} prompt. */ -interface AutocompleteSharedOptions extends CommonOptions { +type AutocompleteSharedOptions = CommonOptions & { /** * The message or question shown to the user above the input. */ message: string; - /** - * The options to present, or a function that returns the options to present - * allowing for custom search/filtering. - * - * @see https://bomb.sh/docs/clack/packages/prompts/#dynamic-options-getter - */ - options: Option[] | ((this: AutocompletePrompt>) => Option[]); - /** * The maximum number of items/options to display in the autocomplete list at once. */ @@ -76,14 +86,51 @@ interface AutocompleteSharedOptions extends CommonOptions { * to show as a validation error, or `undefined` to accept the result. */ validate?: Validate; +} & (AutocompleteSharedOptionsSync | AutocompleteSharedOptionsAsync); +interface AutocompleteSharedOptionsSync { + /** + * The options to present, or a function that returns the options to present + * allowing for custom search/filtering. + * + * @see https://bomb.sh/docs/clack/packages/prompts/#dynamic-options-getter + */ + options: Option[] | ((this: AutocompletePrompt>) => Option[]); /** * Custom filter function to match options against the search input. */ filter?: (search: string, option: Option) => boolean; } -export interface AutocompleteOptions extends AutocompleteSharedOptions { +interface AutocompleteSharedOptionsAsync { + /** + * Available async options for the autocomplete prompt. + */ + options: ( + this: AutocompletePrompt>, + signal?: AbortSignal + ) => Promise[]>; + /** + * Frames to show during the loading of the options. + */ + frames?: string[]; + /** + * Interval between each frame. + */ + interval?: number; + /** + * Debounce for user inputs before doing getting new options. + */ + debounce?: number; + /** + * Custom filter function to match options against search input. + * - null (default): not filter function will be used. + * - undefined: a default filter that matches label, hint, and value is used. + */ + filter?: ((search: string, option: Option) => boolean); +} + +export type AutocompleteOptions = AutocompleteSharedOptions & { /** * The initially selected option from the list. */ @@ -127,27 +174,31 @@ export interface AutocompleteOptions extends AutocompleteSharedOptions(opts: AutocompleteOptions) => { - const prompt = new AutocompletePrompt({ - options: opts.options, + const frames = ('frames' in opts && opts.frames) || S_SPINNER; + + let prompt: AutocompletePrompt>; + + const sharedConfig = { initialValue: opts.initialValue ? [opts.initialValue] : undefined, initialUserInput: opts.initialUserInput, placeholder: opts.placeholder, completeOnTab: opts.completeOnTab, - filter: - opts.filter ?? - ((search: string, opt: Option) => { - return getFilteredOption(search, opt); - }), signal: opts.signal, input: opts.input, output: opts.output, validate: opts.validate, - render() { + render(this: AutocompletePrompt>) { + const promptSymbol = this.isLoading + ? styleText('magenta', frames[this.spinnerIndex]!) + : symbol(this.state); + const hasGuide = opts.withGuide ?? settings.withGuide; + const guide = hasGuide ? styleText('gray', S_BAR) : ''; + // Title and message display const headings = hasGuide - ? [`${styleText('gray', S_BAR)}`, `${symbol(this.state)} ${opts.message}`] - : [`${symbol(this.state)} ${opts.message}`]; + ? [guide, `${promptSymbol} ${opts.message}`] + : [`${promptSymbol} ${opts.message}`]; const userInput = this.userInput; const options = this.options; const placeholder = opts.placeholder; @@ -175,16 +226,14 @@ export const autocomplete = (opts: AutocompleteOptions) => { const selected = getSelectedOptions(this.selectedValues, options); const label = selected.length > 0 ? ` ${styleText('dim', selected.map(getLabel).join(', '))}` : ''; - const submitPrefix = hasGuide ? styleText('gray', S_BAR) : ''; - return `${headings.join('\n')}\n${submitPrefix}${label}`; + return `${headings.join('\n')}\n${guide}${label}`; } case 'cancel': { const userInputText = userInput ? ` ${styleText(['strikethrough', 'dim'], userInput)}` : ''; - const cancelPrefix = hasGuide ? styleText('gray', S_BAR) : ''; - return `${headings.join('\n')}\n${cancelPrefix}${userInputText}`; + return `${headings.join('\n')}\n${guide}${userInputText}`; } default: { @@ -211,7 +260,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { // No matches message const noResults = - this.filteredOptions.length === 0 && userInput + this.filteredOptions.length === 0 && userInput && !this.isLoading ? [`${guidePrefix}${styleText('yellow', 'No matches found')}`] : []; @@ -265,7 +314,34 @@ export const autocomplete = (opts: AutocompleteOptions) => { } } }, - }); + }; + + // Create autocomplete prompt based on if the option is async or not + if ( + isAsync, AutocompleteSharedOptionsAsync>( + opts, + 'options' + ) + ) { + prompt = new AutocompletePrompt>({ + ...sharedConfig, + options: opts.options, + frameCount: frames.length, + interval: opts.interval ?? N_INTERVAL, + debounce: opts.debounce, + filter: getAsyncFilter(opts), + }); + } else { + prompt = new AutocompletePrompt>({ + ...sharedConfig, + options: opts.options, + filter: + opts.filter ?? + ((search: string, opt: Option) => { + return getFilteredOption(search, opt); + }), + }); + } // Return the result or cancel symbol return prompt.prompt() as Promise; @@ -274,7 +350,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { /** * Options for the {@link autocompleteMultiselect} prompt */ -export interface AutocompleteMultiSelectOptions extends AutocompleteSharedOptions { +export type AutocompleteMultiSelectOptions = AutocompleteSharedOptions & { /** * The initially selected option(s) from the list. */ @@ -285,7 +361,7 @@ export interface AutocompleteMultiSelectOptions extends AutocompleteShare * @default false */ required?: boolean; -} +}; /** * The `autocompleteMultiselect` prompt combines the search functionality of autocomplete @@ -312,6 +388,10 @@ export interface AutocompleteMultiSelectOptions extends AutocompleteShare * ``` */ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOptions) => { + const frames = ('frames' in opts && opts.frames) || S_SPINNER; + + let prompt: AutocompletePrompt>; + const formatOption = ( option: Option, active: boolean, @@ -338,15 +418,9 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti }; // Create text prompt which we'll use as foundation - const prompt = new AutocompletePrompt>({ - options: opts.options, + const sharedConfig = { multiple: true, placeholder: opts.placeholder, - filter: - opts.filter ?? - ((search, opt) => { - return getFilteredOption(search, opt); - }), validate: () => { if (opts.required && prompt.selectedValues.length === 0) { return 'Please select at least one item'; @@ -357,12 +431,16 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti signal: opts.signal, input: opts.input, output: opts.output, - render() { + render(this: AutocompletePrompt>) { + const promptSymbol = this.isLoading + ? styleText('magenta', frames[this.spinnerIndex]!) + : symbol(this.state); + const hasGuide = opts.withGuide ?? settings.withGuide; + // Title and symbol - const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${ - opts.message - }\n`; + const titleGuide = hasGuide ? `${styleText('gray', S_BAR)}\n` : ''; + const title = `${titleGuide}${promptSymbol} ${opts.message}\n`; // Selection counter const userInput = this.userInput; @@ -385,16 +463,18 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti ) : ''; + + const inactiveGuidePrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : ''; // Render prompt state switch (this.state) { case 'submit': { - return `${title}${hasGuide ? `${styleText('gray', S_BAR)} ` : ''}${styleText( + return `${title}${inactiveGuidePrefix}${styleText( 'dim', `${this.selectedValues.length} items selected` )}`; } case 'cancel': { - return `${title}${hasGuide ? `${styleText('gray', S_BAR)} ` : ''}${styleText( + return `${title}${inactiveGuidePrefix}${styleText( ['strikethrough', 'dim'], userInput )}`; @@ -413,7 +493,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // No results message const noResults = - this.filteredOptions.length === 0 && userInput + this.filteredOptions.length === 0 && userInput && !this.isLoading ? [`${guidePrefix}${styleText('yellow', 'No matches found')}`] : []; @@ -449,7 +529,34 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti } } }, - }); + }; + + // Create autocomplete prompt based on if the option is async or not + if ( + isAsync, AutocompleteSharedOptionsAsync>( + opts, + 'options' + ) + ) { + prompt = new AutocompletePrompt>({ + ...sharedConfig, + options: opts.options, + frameCount: frames.length, + interval: opts.interval ?? N_INTERVAL, + debounce: opts.debounce, + filter: getAsyncFilter(opts), + }); + } else { + prompt = new AutocompletePrompt>({ + ...sharedConfig, + options: opts.options, + filter: + opts.filter ?? + ((search, opt) => { + return getFilteredOption(search, opt); + }), + }); + } // Return the result or cancel symbol return prompt.prompt() as Promise; diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index e4a0c379..ee256fb3 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -8,7 +8,7 @@ export const isCI = (): boolean => process.env.CI === 'true'; export const isTTY = (output: Writable): boolean => { return (output as Writable & { isTTY?: boolean }).isTTY === true; }; -export const unicodeOr = (c: string, fallback: string) => (unicode ? c : fallback); +export const unicodeOr = (c: T, fallback: T): T => (unicode ? c : fallback); export const S_STEP_ACTIVE = unicodeOr('◆', '*'); export const S_STEP_CANCEL = unicodeOr('■', 'x'); export const S_STEP_ERROR = unicodeOr('▲', 'x'); @@ -39,6 +39,10 @@ export const S_SUCCESS = unicodeOr('◆', '*'); export const S_WARN = unicodeOr('▲', '!'); export const S_ERROR = unicodeOr('■', 'x'); +export const S_SPINNER = unicodeOr(['◒', '◐', '◓', '◑'], ['•', 'o', 'O', '0']); + +export const N_INTERVAL = unicodeOr(80, 120); + export const symbol = (state: State) => { switch (state) { case 'initial': diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 618ae427..4be65b91 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -5,11 +5,12 @@ import { cursor, erase } from 'sisteransi'; import { type CommonOptions, isCI as isCIFn, + N_INTERVAL, S_BAR, + S_SPINNER, S_STEP_CANCEL, S_STEP_ERROR, S_STEP_SUBMIT, - unicode, } from './common.js'; export interface SpinnerOptions extends CommonOptions { @@ -40,8 +41,8 @@ export const spinner = ({ output = process.stdout, cancelMessage, errorMessage, - frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], - delay = unicode ? 80 : 120, + frames = S_SPINNER, + delay = N_INTERVAL, signal, ...opts }: SpinnerOptions = {}): SpinnerResult => {