Skip to content
Draft
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
9 changes: 8 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
217 changes: 170 additions & 47 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 color from 'picocolors';
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,26 +47,56 @@ 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>;
multiple?: boolean;
}
interface AutocompleteOptionsAsync<T extends OptionLike> {
options: (this: AutocompletePrompt<T>, signal?: AbortSignal) => Promise<T[]>;
interval: number;
frameCount: number;
debounce?: number;
filter?: FilterFunction<T> | null;
}

export type AutocompleteOptions<T extends OptionLike> = PromptOptions<
T['value'] | T['value'][],
AutocompletePrompt<T>
> & {
multiple?: 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>;
#options: T[] | (() => T[]);
#filterFn: FilterFunction<T> | null;
#options: AutocompleteOptions<T>['options'];

get cursor(): number {
return this.#cursor;
Expand All @@ -83,48 +114,53 @@ export default class AutocompletePrompt<T extends OptionLike> 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<T>) {
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<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 @@ -138,6 +174,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 @@ -181,28 +278,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 (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];
Expand All @@ -218,6 +328,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 @@ -270,7 +270,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 @@ -117,3 +117,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