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
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
CANCEL_SYMBOL,
getColumns,
getRows,
isAsync,
isCancel,
wrapTextWithPrefix,
} from './utils/index.js';
Expand Down
212 changes: 168 additions & 44 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -46,10 +47,29 @@ function normalisedValue<T>(multiple: boolean, values: T[] | undefined): T | T[]
return values[0];
}

export interface AutocompleteOptions<T extends OptionLike>
extends PromptOptions<T['value'] | T['value'][], AutocompletePrompt<T>> {
function isAsyncOptions<T extends OptionLike>(
options: AutocompleteOptions<T>['options']
): options is AutocompleteOptionsAsync<T>['options'] {
return isAsync({ options }, 'options');
}

interface AutocompleteOptionsSync<T extends OptionLike> {
options: T[] | ((this: AutocompletePrompt<T>) => T[]);
filter?: FilterFunction<T>;
}

interface AutocompleteOptionsAsync<T extends OptionLike> {
options: (this: AutocompletePrompt<T>, signal?: AbortSignal) => Promise<T[]>;
interval: number;
frameCount: number;
debounce?: number;
filter?: FilterFunction<T>;
}

export type AutocompleteOptions<T extends OptionLike> = PromptOptions<
T['value'] | T['value'][],
AutocompletePrompt<T>
> & {
multiple?: boolean;
/**
* When set (non-empty), pressing Tab with no input fills the field with this value
Expand All @@ -63,21 +83,33 @@ export interface AutocompleteOptions<T extends OptionLike>
* currently focused option's value, as if the user had typed it.
*/
completeOnTab?: boolean;
}
} & (AutocompleteOptionsSync<T> | AutocompleteOptionsAsync<T>);

export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
T['value'] | T['value'][]
> {
filteredOptions: T[];
options: T[] = [];
filteredOptions: T[] = [];
multiple: boolean;
isNavigating = false;
selectedValues: Array<T['value']> = [];

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<T> | undefined;
#options: T[] | (() => T[]);
#options: AutocompleteOptions<T>['options'];
#placeholder: string | undefined;
#completeOnTab: boolean;

Expand All @@ -98,51 +130,56 @@ export default class AutocompletePrompt<T extends OptionLike> 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<T>) {
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<AutocompleteOptionsSync<T>, AutocompleteOptionsAsync<T>>(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 {
Expand All @@ -156,6 +193,67 @@ export default class AutocompletePrompt<T extends OptionLike> 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';
Expand Down Expand Up @@ -233,28 +331,41 @@ export default class AutocompletePrompt<T extends OptionLike> 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];
Expand All @@ -270,6 +381,19 @@ export default class AutocompletePrompt<T extends OptionLike> 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);
}
}
2 changes: 1 addition & 1 deletion packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export default class Prompt<TValue> {
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,
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,15 @@ export function wrapTextWithPrefix(
.join('\n');
return lines;
}

/**
* Check if a specific prop is a async function
*/
export function isAsync<Sync extends object, Async extends object>(
opts: Sync | Async,
prop: string
): opts is Async {
const value = (opts as Record<string, unknown>)[prop];

return typeof value === 'function' && value.constructor.name === 'AsyncFunction';
}
Loading
Loading