diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7299d075..2eacf052 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,13 @@ export { default as SelectKeyPrompt } from './prompts/select-key.js'; export type { TextOptions } from './prompts/text.js'; export { default as TextPrompt } from './prompts/text.js'; export type { ClackState as State } from './types.js'; -export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js'; +export { + block, + getColumns, + getRows, + isAsync, + isCancel, + wrapTextWithPrefix, +} from './utils/index.js'; export type { ClackSettings } from './utils/settings.js'; export { settings, updateSettings } from './utils/settings.js'; diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 2602641b..1cf22999 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 color from 'picocolors'; import { findCursor } from '../utils/cursor.js'; +import { isAsync } from '../utils/index.js'; import Prompt, { type PromptOptions } from './prompt.js'; interface OptionLike { @@ -46,26 +47,56 @@ 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; - multiple?: boolean; +} +interface AutocompleteOptionsAsync { + options: (this: AutocompletePrompt, signal?: AbortSignal) => Promise; + interval: number; + frameCount: number; + debounce?: number; + filter?: FilterFunction | null; } +export type AutocompleteOptions = PromptOptions< + T['value'] | T['value'][], + AutocompletePrompt +> & { + multiple?: 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; - #options: T[] | (() => T[]); + #filterFn: FilterFunction | null; + #options: AutocompleteOptions['options']; get cursor(): number { return this.#cursor; @@ -83,48 +114,53 @@ export default class AutocompletePrompt extends Prompt< return `${s1}${color.inverse(s2)}${s3.join('')}`; } - get options(): T[] { - if (typeof this.#options === 'function') { - return this.#options(); - } - return this.#options; - } - constructor(opts: AutocompleteOptions) { super(opts); this.#options = opts.options; - const options = this.options; - this.filteredOptions = [...options]; this.multiple = opts.multiple === true; - this.#filterFn = opts.filter ?? defaultFilter; - let initialValues: unknown[] | undefined; - if (opts.initialValue && Array.isArray(opts.initialValue)) { - if (this.multiple) { - initialValues = opts.initialValue; + this.#filterFn = opts.filter === undefined ? defaultFilter : opts.filter; + + 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 { @@ -138,6 +174,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'; @@ -181,28 +278,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 (value) { - this.filteredOptions = options.filter((opt) => this.#filterFn(value, opt)); + if (isAsyncOptions(this.#options)) { + this.#handleInputChangedAsync(value); + } else { + this.#handleInputChangedSync(value); + } + } + + #handleInputChangedSync(value: string) { + const optionsProcessing = () => { + if (value && this.#filterFn !== null) { + 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]; @@ -218,6 +328,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 b30deb02..7c54c4b4 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -270,7 +270,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 3bb9aac6..cd60e5f0 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -117,3 +117,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 80a01d1d..3066a455 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -1,13 +1,15 @@ -import { AutocompletePrompt, settings } from '@clack/core'; +import { AutocompletePrompt, isAsync, settings } from '@clack/core'; import color from 'picocolors'; 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'; @@ -41,15 +43,29 @@ function getSelectedOptions(values: T[], options: Option[]): Option[] { return results; } -interface AutocompleteSharedOptions extends CommonOptions { +function getAsyncFilter( + opts: AutocompleteOptions +): AutocompleteSharedOptionsAsync['filter'] { + // filter not provided at all + if (!('filter' in opts)) { + return null; + } + + if (opts.filter) { + return opts.filter; + } + + // filter undefined + return (search: string, opt: Option) => { + return getFilteredOption(search, opt); + }; +} + +type AutocompleteSharedOptions = CommonOptions & { /** * The message to display to the user. */ message: string; - /** - * Available options for the autocomplete prompt. - */ - options: Option[] | ((this: AutocompletePrompt>) => Option[]); /** * Maximum number of items to display at once. */ @@ -62,6 +78,13 @@ interface AutocompleteSharedOptions extends CommonOptions { * Validates the value */ validate?: (value: Value | Value[] | undefined) => string | Error | undefined; +} & (AutocompleteSharedOptionsSync | AutocompleteSharedOptionsAsync); + +interface AutocompleteSharedOptionsSync { + /** + * Available options for the autocomplete prompt. + */ + options: Option[] | ((this: AutocompletePrompt>) => Option[]); /** * Custom filter function to match options against search input. * If not provided, a default filter that matches label, hint, and value is used. @@ -69,7 +92,35 @@ interface AutocompleteSharedOptions extends CommonOptions { 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) | null; +} + +export type AutocompleteOptions = AutocompleteSharedOptions & { /** * The initial selected value. */ @@ -78,28 +129,30 @@ 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, - 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 + ? color.magenta(frames[this.spinnerIndex]) + : symbol(this.state); + const hasGuide = opts.withGuide ?? settings.withGuide; // Title and message display const headings = hasGuide - ? [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`] - : [`${symbol(this.state)} ${opts.message}`]; + ? [`${color.gray(S_BAR)}`, `${promptSymbol} ${opts.message}`] + : [`${promptSymbol} ${opts.message}`]; const userInput = this.userInput; const options = this.options; const placeholder = opts.placeholder; @@ -158,7 +211,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { // No matches message const noResults = - this.filteredOptions.length === 0 && userInput + this.filteredOptions.length === 0 && userInput && !this.isLoading ? [`${guidePrefix}${color.yellow('No matches found')}`] : []; @@ -211,14 +264,41 @@ 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; }; // Type definition for the autocompleteMultiselect component -export interface AutocompleteMultiSelectOptions extends AutocompleteSharedOptions { +export type AutocompleteMultiSelectOptions = AutocompleteSharedOptions & { /** * The initial selected values */ @@ -227,12 +307,16 @@ export interface AutocompleteMultiSelectOptions extends AutocompleteShare * If true, at least one option must be selected */ required?: boolean; -} +}; /** * Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI */ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOptions) => { + const frames = ('frames' in opts && opts.frames) || S_SPINNER; + + let prompt: AutocompletePrompt>; + const formatOption = ( option: Option, active: boolean, @@ -257,14 +341,8 @@ 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, - filter: - opts.filter ?? - ((search, opt) => { - return getFilteredOption(search, opt); - }), validate: () => { if (opts.required && prompt.selectedValues.length === 0) { return 'Please select at least one item'; @@ -275,9 +353,13 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti signal: opts.signal, input: opts.input, output: opts.output, - render() { - // Title and symbol - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + render(this: AutocompletePrompt>) { + // Symbol and title + const promptSymbol = this.isLoading + ? color.magenta(frames[this.spinnerIndex]) + : symbol(this.state); + + const title = `${color.gray(S_BAR)}\n${promptSymbol} ${opts.message}\n`; // Selection counter const userInput = this.userInput; @@ -319,7 +401,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // No results message const noResults = - this.filteredOptions.length === 0 && userInput + this.filteredOptions.length === 0 && userInput && !this.isLoading ? [`${barColor(S_BAR)} ${color.yellow('No matches found')}`] : []; @@ -358,7 +440,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 64359053..1ff89a6a 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 15869e76..b94aa4bb 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 => {