diff --git a/.changeset/short-taxis-cross.md b/.changeset/short-taxis-cross.md new file mode 100644 index 00000000..6365d449 --- /dev/null +++ b/.changeset/short-taxis-cross.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Remove `suggestion` prompt and change `path` prompt to be an autocomplete prompt. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 569f506d..ca4103f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export type { ClackState as State, ValueWithCursorPart } from './types.js'; +export type { ClackState as State } from './types.js'; export type { ClackSettings } from './utils/settings.js'; export { default as ConfirmPrompt } from './prompts/confirm.js'; @@ -10,6 +10,5 @@ export { default as SelectPrompt } from './prompts/select.js'; export { default as SelectKeyPrompt } from './prompts/select-key.js'; export { default as TextPrompt } from './prompts/text.js'; export { default as AutocompletePrompt } from './prompts/autocomplete.js'; -export { default as SuggestionPrompt } from './prompts/suggestion.js'; export { block, isCancel, getColumns } from './utils/index.js'; export { updateSettings, settings } from './utils/settings.js'; diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index d1701212..05bc8cf9 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -46,7 +46,7 @@ function normalisedValue(multiple: boolean, values: T[] | undefined): T | T[] interface AutocompleteOptions extends PromptOptions> { - options: T[]; + options: T[] | ((this: AutocompletePrompt) => T[]); filter?: FilterFunction; multiple?: boolean; } @@ -54,7 +54,6 @@ interface AutocompleteOptions export default class AutocompletePrompt extends Prompt< T['value'] | T['value'][] > { - options: T[]; filteredOptions: T[]; multiple: boolean; isNavigating = false; @@ -64,6 +63,7 @@ export default class AutocompletePrompt extends Prompt< #cursor = 0; #lastUserInput = ''; #filterFn: FilterFunction; + #options: T[] | (() => T[]); get cursor(): number { return this.#cursor; @@ -81,11 +81,19 @@ 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; - this.filteredOptions = [...this.options]; + 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; @@ -103,7 +111,7 @@ export default class AutocompletePrompt extends Prompt< if (initialValues) { for (const selectedValue of initialValues) { - const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue); + const selectedIndex = options.findIndex((opt) => opt.value === selectedValue); if (selectedIndex !== -1) { this.toggleSelected(selectedValue); this.#cursor = selectedIndex; @@ -113,16 +121,6 @@ export default class AutocompletePrompt extends Prompt< this.focusedValue = this.options[this.#cursor]?.value; - this.on('finalize', () => { - if (!this.value) { - this.value = normalisedValue(this.multiple, initialValues); - } - - if (this.state === 'submit') { - this.value = normalisedValue(this.multiple, this.selectedValues); - } - }); - this.on('key', (char, key) => this.#onKey(char, key)); this.on('userInput', (value) => this.#onUserInputChanged(value)); } @@ -141,6 +139,7 @@ export default class AutocompletePrompt extends Prompt< #onKey(_char: string | undefined, key: Key): void { const isUpKey = key.name === 'up'; const isDownKey = key.name === 'down'; + const isReturnKey = key.name === 'return'; // Start navigation mode with up/down arrows if (isUpKey || isDownKey) { @@ -153,6 +152,8 @@ export default class AutocompletePrompt extends Prompt< this.selectedValues = [this.focusedValue]; } this.isNavigating = true; + } else if (isReturnKey) { + this.value = normalisedValue(this.multiple, this.selectedValues); } else { if (this.multiple) { if ( @@ -171,6 +172,10 @@ export default class AutocompletePrompt extends Prompt< } } + deselectAll() { + this.selectedValues = []; + } + toggleSelected(value: T['value']) { if (this.filteredOptions.length === 0) { return; @@ -191,13 +196,22 @@ export default class AutocompletePrompt extends Prompt< if (value !== this.#lastUserInput) { this.#lastUserInput = value; + const options = this.options; + if (value) { - this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt)); + this.filteredOptions = options.filter((opt) => this.#filterFn(value, opt)); } else { - this.filteredOptions = [...this.options]; + this.filteredOptions = [...options]; } this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions); this.focusedValue = this.filteredOptions[this.#cursor]?.value; + if (!this.multiple) { + if (this.focusedValue !== undefined) { + this.toggleSelected(this.focusedValue); + } else { + this.deselectAll(); + } + } } } } diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 119d2998..ca76b285 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -13,6 +13,7 @@ import type { Action } from '../utils/index.js'; export interface PromptOptions> { render(this: Omit): string | undefined; initialValue?: any; + initialUserInput?: string; validate?: ((value: TValue | undefined) => string | Error | undefined) | undefined; input?: Readable; output?: Writable; @@ -25,7 +26,7 @@ export default class Prompt { protected output: Writable; private _abortSignal?: AbortSignal; - protected rl: ReadLine | undefined; + private rl: ReadLine | undefined; private opts: Omit>, 'render' | 'input' | 'output'>; private _render: (context: Omit, 'prompt'>) => string | undefined; private _track = false; @@ -145,7 +146,9 @@ export default class Prompt { }); this.rl.prompt(); - this.emit('beforePrompt'); + if (this.opts.initialUserInput !== undefined) { + this._setUserInput(this.opts.initialUserInput, true); + } this.input.on('keypress', this.onKeypress); setRawMode(this.input, true); diff --git a/packages/core/src/prompts/suggestion.ts b/packages/core/src/prompts/suggestion.ts deleted file mode 100644 index ebaac138..00000000 --- a/packages/core/src/prompts/suggestion.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { Key } from 'node:readline'; -import type { ValueWithCursorPart } from '../types.js'; -import Prompt, { type PromptOptions } from './prompt.js'; - -interface SuggestionOptions extends PromptOptions { - suggest: (value: string) => Array; - initialValue: string; -} - -export default class SuggestionPrompt extends Prompt { - protected suggest: (value: string) => Array; - private selectionIndex = 0; - private nextItems: Array = []; - - constructor(opts: SuggestionOptions) { - super(opts); - - this.suggest = opts.suggest; - this.getNextItems(); - this.selectionIndex = 0; - - this.on('beforePrompt', () => { - if (opts.initialValue !== undefined) { - this._setUserInput(opts.initialValue, true); - } - }); - this.on('cursor', (key) => { - switch (key) { - case 'up': - this.selectionIndex = Math.max( - 0, - this.selectionIndex === 0 ? this.nextItems.length - 1 : this.selectionIndex - 1 - ); - this.value = this.nextItems[this.selectionIndex]; - break; - case 'down': - this.selectionIndex = - this.nextItems.length === 0 ? 0 : (this.selectionIndex + 1) % this.nextItems.length; - this.value = this.nextItems[this.selectionIndex]; - break; - } - }); - this.on('key', (_key, info) => { - const nextItem = this.nextItems[this.selectionIndex]; - if (info.name === 'tab' && nextItem !== undefined) { - const delta = nextItem.substring(this.userInput.length); - // TODO (43081j): this means the selected value won't show up until we - // later choose another value. probably shouldn't set `value` until - // finalize tbh - this.value = nextItem; - this.rl?.write(delta); - this._cursor = this.rl?.cursor ?? 0; - this.selectionIndex = 0; - this._setUserInput(this.userInput + delta); - } - }); - this.on('userInput', () => { - if (this.value !== this.userInput) { - this.value = this.userInput; - } - - this.getNextItems(); - }); - } - - get displayValue(): Array { - const result: Array = []; - if (this._cursor > 0) { - result.push({ - text: this.userInput.substring(0, this._cursor), - type: 'value', - }); - } - if (this._cursor < this.userInput.length) { - result.push({ - text: this.userInput.substring(this._cursor, this._cursor + 1), - type: 'cursor_on_value', - }); - const left = this.userInput.substring(this._cursor + 1); - if (left.length > 0) { - result.push({ - text: left, - type: 'value', - }); - } - if (this.suggestion.length > 0) { - result.push({ - text: this.suggestion, - type: 'suggestion', - }); - } - return result; - } - if (this.suggestion.length === 0) { - result.push({ - text: '\u00A0', - type: 'cursor_on_value', - }); - return result; - } - result.push( - { - text: this.suggestion[0], - type: 'cursor_on_suggestion', - }, - { - text: this.suggestion.substring(1), - type: 'suggestion', - } - ); - return result; - } - - get suggestion(): string { - return this.nextItems[this.selectionIndex]?.substring(this.userInput.length) ?? ''; - } - - private getNextItems(): void { - this.nextItems = this.suggest(this.userInput).filter((item) => { - return item.startsWith(this.userInput) && item !== this.value; - }); - if (this.selectionIndex > this.nextItems.length) { - this.selectionIndex = 0; - } - } -} diff --git a/packages/core/src/prompts/text.ts b/packages/core/src/prompts/text.ts index 057d7e5f..c76a5188 100644 --- a/packages/core/src/prompts/text.ts +++ b/packages/core/src/prompts/text.ts @@ -23,13 +23,11 @@ export default class TextPrompt extends Prompt { return this._cursor; } constructor(opts: TextOptions) { - super(opts); - - this.on('beforePrompt', () => { - if (opts.initialValue !== undefined) { - this._setUserInput(opts.initialValue, true); - } + super({ + ...opts, + initialUserInput: opts.initialUserInput ?? opts.initialValue, }); + this.on('userInput', (input) => { this._setValue(input); }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 40f2c0c6..a4a56405 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -23,11 +23,3 @@ export interface ClackEvents { finalize: () => void; beforePrompt: () => void; } - -/** - * Display a value - */ -export interface ValueWithCursorPart { - text: string; - type: 'value' | 'cursor_on_value' | 'suggestion' | 'cursor_on_suggestion'; -} diff --git a/packages/core/test/prompts/suggestion.test.ts b/packages/core/test/prompts/suggestion.test.ts deleted file mode 100644 index 1551add3..00000000 --- a/packages/core/test/prompts/suggestion.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import color from 'picocolors'; -import { cursor } from 'sisteransi'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { default as SelectPrompt } from '../../src/prompts/select.js'; -import { default as SuggestionPrompt } from '../../src/prompts/suggestion.js'; -import { MockReadable } from '../mock-readable.js'; -import { MockWritable } from '../mock-writable.js'; - -describe(SuggestionPrompt.name, () => { - let input: MockReadable; - let output: MockWritable; - - beforeEach(() => { - input = new MockReadable(); - output = new MockWritable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('displayValue getter return all parts/cases', () => { - test('no suggestion, cursor at the end', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => [], - initialValue: 'Lorem ipsum', - render: () => 'Lorem ipsum', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect(instance.displayValue).to.deep.equal([ - { text: 'Lorem ipsum', type: 'value' }, - { text: '\u00a0', type: 'cursor_on_value' }, - ]); - }); - - test('no suggestion, cursor at the start', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => [], - initialValue: 'Lorem', - render: () => 'Lorem', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - for (let index = 0; index < 5; index++) input.emit('keypress', '', { name: 'left' }); - expect(instance.displayValue).to.deep.equal([ - { text: 'L', type: 'cursor_on_value' }, - { text: 'orem', type: 'value' }, - ]); - }); - test('no suggestion, cursor in the middle', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => [], - initialValue: 'Lorem', - render: () => 'Lorem', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - for (let index = 0; index < 3; index++) input.emit('keypress', '', { name: 'left' }); - expect(instance.displayValue).to.deep.equal([ - { text: 'Lo', type: 'value' }, - { text: 'r', type: 'cursor_on_value' }, - { text: 'em', type: 'value' }, - ]); - }); - test('no suggestion, cursor on the last letter', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => [], - initialValue: 'Lorem', - render: () => 'Lorem', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - input.emit('keypress', '', { name: 'left' }); - expect(instance.displayValue).to.deep.equal([ - { text: 'Lore', type: 'value' }, - { text: 'm', type: 'cursor_on_value' }, - ]); - }); - test('with suggestion, cursor at the end', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit'], - initialValue: 'Lorem ipsum dolor sit amet', - render: () => 'Lorem ipsum dolor sit amet', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect(instance.displayValue).to.deep.equal([ - { text: 'Lorem ipsum dolor sit amet', type: 'value' }, - { text: ',', type: 'cursor_on_suggestion' }, - { text: ' consectetur adipiscing elit', type: 'suggestion' }, - ]); - }); - test('with suggestion, cursor not at the end', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit'], - initialValue: 'Lorem ipsum dolor sit amet', - render: () => 'Lorem ipsum dolor sit amet', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - for (let index = 0; index < 3; index++) input.emit('keypress', '', { name: 'left' }); - expect(instance.displayValue).to.deep.equal([ - { text: 'Lorem ipsum dolor sit a', type: 'value' }, - { text: 'm', type: 'cursor_on_value' }, - { text: 'et', type: 'value' }, - { text: ', consectetur adipiscing elit', type: 'suggestion' }, - ]); - }); - }); - describe('navigate suggestion', () => { - test('the default is the first suggestion', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['foobar', 'foobaz'], - initialValue: 'foo', - render: () => 'foo', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect(instance.suggestion).to.be.equal('bar'); - }); - test('down display next suggestion', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['foobar', 'foobaz'], - initialValue: 'foo', - render: () => 'foo', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - input.emit('keypress', '', { name: 'down' }); - - expect(instance.suggestion).to.be.equal('baz'); - }); - test('suggestions loops (down)', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['foobar', 'foobaz'], - initialValue: 'foo', - render: () => 'foo', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect(instance.suggestion).to.be.equal('bar'); - input.emit('keypress', '', { name: 'down' }); - expect(instance.suggestion).to.be.equal('baz'); - input.emit('keypress', '', { name: 'down' }); - expect(instance.suggestion).to.be.equal('bar'); - }); - - test('suggestions loops (up)', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['foobar', 'foobaz'], - initialValue: 'foo', - render: () => 'foo', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect(instance.suggestion).to.be.equal('bar'); - input.emit('keypress', '', { name: 'up' }); - expect(instance.suggestion).to.be.equal('baz'); - input.emit('keypress', '', { name: 'up' }); - expect(instance.suggestion).to.be.equal('bar'); - }); - }); - test('tab validate suggestion', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['foobar', 'foobaz'], - initialValue: 'foo', - render: () => 'foo', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect(instance.suggestion).to.be.equal('bar'); - expect(instance.value).to.be.equal('foo'); - input.emit('keypress', '', { name: 'tab' }); - expect(instance.suggestion).to.be.equal(''); - expect(instance.value).to.be.equal('foobar'); - }); - describe('suggestion are filtered', () => { - test("suggestion that don't match (begin) at not displayed", () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['foobar', 'foobaz', 'hello world'], - initialValue: 'foo', - render: () => 'foo', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect((instance as unknown as { nextItems: Array }).nextItems.length).to.be.equal(2); - }); - test('empty suggestions are removed', () => { - const instance = new SuggestionPrompt({ - input, - output, - suggest: () => ['foo'], - initialValue: 'foo', - render: () => 'foo', - }); - // leave the promise hanging since we don't want to submit in this test - instance.prompt(); - expect((instance as unknown as { nextItems: Array }).nextItems.length).to.be.equal(0); - }); - }); -}); diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 698641de..c9b36696 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -49,7 +49,7 @@ interface AutocompleteSharedOptions extends CommonOptions { /** * Available options for the autocomplete prompt. */ - options: Option[]; + options: Option[] | ((this: AutocompletePrompt>) => Option[]); /** * Maximum number of items to display at once. */ @@ -58,6 +58,10 @@ interface AutocompleteSharedOptions extends CommonOptions { * Placeholder text to display when no input is provided. */ placeholder?: string; + /** + * Validates the value + */ + validate?: (value: Value | Value[] | undefined) => string | Error | undefined; } export interface AutocompleteOptions extends AutocompleteSharedOptions { @@ -65,22 +69,29 @@ export interface AutocompleteOptions extends AutocompleteSharedOptions(opts: AutocompleteOptions) => { const prompt = new AutocompletePrompt({ options: opts.options, initialValue: opts.initialValue ? [opts.initialValue] : undefined, + initialUserInput: opts.initialUserInput, filter: (search: string, opt: Option) => { return getFilteredOption(search, opt); }, input: opts.input, output: opts.output, + validate: opts.validate, render() { // Title and message display const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const userInput = this.userInput; const valueAsString = String(this.value ?? ''); + const options = this.options; const placeholder = opts.placeholder; const showPlaceholder = valueAsString === '' && placeholder !== undefined; @@ -88,7 +99,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { switch (this.state) { case 'submit': { // Show selected value - const selected = getSelectedOptions(this.selectedValues, this.options); + const selected = getSelectedOptions(this.selectedValues, options); const label = selected.length > 0 ? selected.map(getLabel).join(', ') : ''; return `${title}${color.gray(S_BAR)} ${color.dim(label)}`; } @@ -106,7 +117,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { // Show match count if filtered const matches = - this.filteredOptions.length !== this.options.length + this.filteredOptions.length !== options.length ? color.dim( ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` ) @@ -147,11 +158,15 @@ export const autocomplete = (opts: AutocompleteOptions) => { ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] : []; + const validationError = + this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : []; + // Return the formatted prompt return [ title, `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, ...noResults, + ...validationError, ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, `${color.cyan(S_BAR_END)}`, @@ -232,8 +247,10 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti ? color.dim(showPlaceholder ? placeholder : userInput) // Just show plain text when in navigation mode : this.userInputWithCursor; + const options = this.options; + const matches = - this.filteredOptions.length !== opts.options.length + this.filteredOptions.length !== options.length ? color.dim( ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})` ) diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index d132f341..ad330613 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -74,17 +74,18 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const unselectedCheckbox = isItem || selectableGroups ? color.dim(S_CHECKBOX_INACTIVE) : ''; return `${spacingPrefix}${color.dim(prefix)}${unselectedCheckbox} ${color.dim(label)}`; }; + const required = opts.required ?? true; return new GroupMultiSelectPrompt({ options: opts.options, input: opts.input, output: opts.output, initialValues: opts.initialValues, - required: opts.required ?? true, + required, cursorAt: opts.cursorAt, selectableGroups, validate(selected: Value[] | undefined) { - if (this.required && (selected === undefined || selected.length === 0)) + if (required && (selected === undefined || selected.length === 0)) return `Please select at least one option.\n${color.reset( color.dim( `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index e316ef65..0dcced96 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -17,7 +17,6 @@ export * from './select-key.js'; export * from './select.js'; export * from './spinner.js'; export * from './stream.js'; -export * from './suggestion.js'; export * from './task.js'; export * from './task-log.js'; export * from './text.js'; diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 661231ed..1512ae3b 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -49,16 +49,17 @@ export const multiselect = (opts: MultiSelectOptions) => { } return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; }; + const required = opts.required ?? true; return new MultiSelectPrompt({ options: opts.options, input: opts.input, output: opts.output, initialValues: opts.initialValues, - required: opts.required ?? true, + required, cursorAt: opts.cursorAt, validate(selected: Value[] | undefined) { - if (this.required && (selected === undefined || selected.length === 0)) + if (required && (selected === undefined || selected.length === 0)) return `Please select at least one option.\n${color.reset( color.dim( `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index 567a7bd9..559acfa2 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -1,8 +1,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { dirname } from 'knip/dist/util/path.js'; +import { dirname, join } from 'node:path'; +import { autocomplete } from './autocomplete.js'; import type { CommonOptions } from './common.js'; -import { suggestion } from './suggestion.js'; export interface PathOptions extends CommonOptions { root?: string; @@ -13,15 +12,45 @@ export interface PathOptions extends CommonOptions { } export const path = (opts: PathOptions) => { - return suggestion({ + const validate = opts.validate; + + return autocomplete({ ...opts, - initialValue: opts.initialValue ?? opts.root ?? process.cwd(), - suggest: (value: string) => { + initialUserInput: opts.initialValue ?? opts.root ?? process.cwd(), + maxItems: 5, + validate(value) { + if (Array.isArray(value)) { + // Shouldn't ever happen since we don't enable `multiple: true` + return undefined; + } + if (!value) { + return 'Please select a path'; + } + if (validate) { + return validate(value); + } + return undefined; + }, + options() { + const userInput = this.userInput; + if (userInput === '') { + return []; + } + try { - const searchPath = !existsSync(value) ? dirname(value) : value; - if (!lstatSync(searchPath).isDirectory()) { - return []; + let searchPath: string; + + if (!existsSync(userInput)) { + searchPath = dirname(userInput); + } else { + const stat = lstatSync(userInput); + if (stat.isDirectory()) { + searchPath = userInput; + } else { + searchPath = dirname(userInput); + } } + const items = readdirSync(searchPath) .map((item) => { const path = join(searchPath, item); @@ -32,10 +61,13 @@ export const path = (opts: PathOptions) => { isDirectory: stats.isDirectory(), }; }) - .filter(({ path }) => path.startsWith(value)); - return ((opts.directory ?? false) ? items.filter((item) => item.isDirectory) : items).map( - ({ path }) => path - ); + .filter( + ({ path, isDirectory }) => + path.startsWith(userInput) && (opts.directory || !isDirectory) + ); + return items.map((item) => ({ + value: item.path, + })); } catch (e) { return []; } diff --git a/packages/prompts/src/suggestion.ts b/packages/prompts/src/suggestion.ts deleted file mode 100644 index 2c669560..00000000 --- a/packages/prompts/src/suggestion.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SuggestionPrompt, type ValueWithCursorPart } from '@clack/core'; -import color from 'picocolors'; -import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; - -export interface SuggestionOptions extends CommonOptions { - initialValue?: string; - message: string; - validate?: (value: string | undefined) => string | Error | undefined; - suggest: (value: string) => Array; -} - -export const suggestion = (opts: SuggestionOptions) => { - return new SuggestionPrompt({ - initialValue: opts.initialValue ?? '', - output: opts.output, - input: opts.input, - validate: opts.validate, - suggest: opts.suggest, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const value = this.displayValue.reduce((text: string, line: ValueWithCursorPart) => { - switch (line.type) { - case 'value': - return text + line.text; - case 'cursor_on_value': - return text + color.inverse(line.text); - case 'suggestion': - return text + color.gray(line.text); - case 'cursor_on_suggestion': - return text + color.inverse(color.gray(line.text)); - } - }, ''); - - switch (this.state) { - case 'error': - return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( - S_BAR_END - )} ${color.yellow(this.error)}\n`; - case 'submit': - return `${title}${color.gray(S_BAR)} ${color.dim(this.value)}`; - case 'cancel': - return `${title}${color.gray(S_BAR)} ${color.strikethrough( - color.dim(this.value ?? '') - )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`; - default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; - } - }, - }).prompt() as Promise; -}; diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index d827e9cf..1409e5c6 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -173,7 +173,7 @@ exports[`autocomplete > shows no matches message when search has no results 1`] "", "", "◇ Select a fruit -│ Apple", +│", " ", "", diff --git a/packages/prompts/test/__snapshots__/path.test.ts.snap b/packages/prompts/test/__snapshots__/path.test.ts.snap index ce1138df..181952f8 100644 --- a/packages/prompts/test/__snapshots__/path.test.ts.snap +++ b/packages/prompts/test/__snapshots__/path.test.ts.snap @@ -5,30 +5,69 @@ exports[`text (isCI = false) > can cancel 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "■ foo -│ /tmp/ -│", +│ /tmp/", " ", "", ] `; -exports[`text (isCI = false) > initialValue sets the value 1`] = ` +exports[`text (isCI = false) > cannot submit unknown value 1`] = ` [ "", "│ ◆ foo -│ /tmp/bar  -└ -", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/_█ +│ No matches found +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "▲ foo + +│ Search: /tmp/_█ +│ No matches found +│ Please select a path +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◆ foo + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo @@ -39,24 +78,21 @@ exports[`text (isCI = false) > initialValue sets the value 1`] = ` ] `; -exports[`text (isCI = false) > renders and apply () suggestion 1`] = ` +exports[`text (isCI = false) > initialValue sets the value 1`] = ` [ "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/foo/bar.txt", - "", - "", + +│ Search: /tmp/bar█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo -│ /tmp/foo", +│ /tmp/bar", " ", "", @@ -68,25 +104,29 @@ exports[`text (isCI = false) > renders cancelled value if one set 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/x ", - "", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/x█ +│ No matches found +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", "", - "│ /tmp/xy ", - "", - "", + "│ Search: /tmp/xy█", + "", + "", "", "", "■ foo -│ /tmp/xy -│", +│ /tmp/xy", " ", "", @@ -98,14 +138,17 @@ exports[`text (isCI = false) > renders message 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo -│ /tmp/", +│ /tmp/bar", " ", "", @@ -117,24 +160,29 @@ exports[`text (isCI = false) > renders submitted value 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/x ", - "", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", "", - "│ /tmp/xy ", - "", - "", + "│ Search: /tmp/ba█", + "", + "", "", "", "◇ foo -│ /tmp/xy", +│ /tmp/bar", " ", "", @@ -146,34 +194,47 @@ exports[`text (isCI = false) > validation errors render and clear (using Error) "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/b ", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/r█ +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "▲ foo -│ /tmp/b  -└ should be /tmp/bar -", - "", + +│ Search: /tmp/r█ +│ should be /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◆ foo -│ /tmp/ba  -└ -", - "", - "", - "", - "│ /tmp/bar ", - "", - "", + +│ Search: /tmp/█ +│ ○ /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo @@ -189,34 +250,47 @@ exports[`text (isCI = false) > validation errors render and clear 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/b ", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/r█ +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "▲ foo -│ /tmp/b  -└ should be /tmp/bar -", - "", + +│ Search: /tmp/r█ +│ should be /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◆ foo -│ /tmp/ba  -└ -", - "", - "", - "", - "│ /tmp/bar ", - "", - "", + +│ Search: /tmp/█ +│ ○ /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo @@ -232,30 +306,69 @@ exports[`text (isCI = true) > can cancel 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "■ foo -│ /tmp/ -│", +│ /tmp/", " ", "", ] `; -exports[`text (isCI = true) > initialValue sets the value 1`] = ` +exports[`text (isCI = true) > cannot submit unknown value 1`] = ` [ "", "│ ◆ foo -│ /tmp/bar  -└ -", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/_█ +│ No matches found +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "▲ foo + +│ Search: /tmp/_█ +│ No matches found +│ Please select a path +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◆ foo + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo @@ -266,24 +379,21 @@ exports[`text (isCI = true) > initialValue sets the value 1`] = ` ] `; -exports[`text (isCI = true) > renders and apply () suggestion 1`] = ` +exports[`text (isCI = true) > initialValue sets the value 1`] = ` [ "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/foo/bar.txt", - "", - "", + +│ Search: /tmp/bar█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo -│ /tmp/foo", +│ /tmp/bar", " ", "", @@ -295,25 +405,29 @@ exports[`text (isCI = true) > renders cancelled value if one set 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/x ", - "", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/x█ +│ No matches found +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", "", - "│ /tmp/xy ", - "", - "", + "│ Search: /tmp/xy█", + "", + "", "", "", "■ foo -│ /tmp/xy -│", +│ /tmp/xy", " ", "", @@ -325,14 +439,17 @@ exports[`text (isCI = true) > renders message 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo -│ /tmp/", +│ /tmp/bar", " ", "", @@ -344,24 +461,29 @@ exports[`text (isCI = true) > renders submitted value 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/x ", - "", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", "", - "│ /tmp/xy ", - "", - "", + "│ Search: /tmp/ba█", + "", + "", "", "", "◇ foo -│ /tmp/xy", +│ /tmp/bar", " ", "", @@ -373,34 +495,47 @@ exports[`text (isCI = true) > validation errors render and clear (using Error) 1 "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/b ", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/r█ +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "▲ foo -│ /tmp/b  -└ should be /tmp/bar -", - "", + +│ Search: /tmp/r█ +│ should be /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◆ foo -│ /tmp/ba  -└ -", - "", - "", - "", - "│ /tmp/bar ", - "", - "", + +│ Search: /tmp/█ +│ ○ /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo @@ -416,34 +551,47 @@ exports[`text (isCI = true) > validation errors render and clear 1`] = ` "", "│ ◆ foo -│ /tmp/foo -└ -", - "", - "", - "", - "│ /tmp/b ", - "", - "", + +│ Search: /tmp/█ +│ ● /tmp/bar +│ ○ /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/r█ +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "▲ foo -│ /tmp/b  -└ should be /tmp/bar -", - "", + +│ Search: /tmp/r█ +│ should be /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◆ foo -│ /tmp/ba  -└ -", - "", - "", - "", - "│ /tmp/bar ", - "", - "", + +│ Search: /tmp/█ +│ ○ /tmp/bar +│ ● /tmp/root.zip +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: /tmp/b█ +│ ● /tmp/bar +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", "", "", "◇ foo diff --git a/packages/prompts/test/__snapshots__/suggestion.test.ts.snap b/packages/prompts/test/__snapshots__/suggestion.test.ts.snap deleted file mode 100644 index e1f2aeeb..00000000 --- a/packages/prompts/test/__snapshots__/suggestion.test.ts.snap +++ /dev/null @@ -1,433 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`text (isCI = false) > can cancel 1`] = ` -[ - "", - "│ -◆ foo -│   -└ -", - "", - "", - "", - "■ foo -│", - " -", - "", -] -`; - -exports[`text (isCI = false) > initialValue sets the value 1`] = ` -[ - "", - "│ -◆ foo -│ bar  -└ -", - "", - "", - "", - "◇ foo -│ bar", - " -", - "", -] -`; - -exports[`text (isCI = false) > renders and apply () suggestion 1`] = ` -[ - "", - "│ -◆ foo -│ bar -└ -", - "", - "", - "", - "│ bar ", - "", - "", - "", - "", - "◇ foo -│ bar", - " -", - "", -] -`; - -exports[`text (isCI = false) > renders cancelled value if one set 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "■ foo -│ xy -│", - " -", - "", -] -`; - -exports[`text (isCI = false) > renders message 1`] = ` -[ - "", - "│ -◆ foo -│   -└ -", - "", - "", - "", - "◇ foo -│", - " -", - "", -] -`; - -exports[`text (isCI = false) > renders submitted value 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "◇ foo -│ xy", - " -", - "", -] -`; - -exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz -└ -", - "", - "", - "", - "◇ foo -│ xy", - " -", - "", -] -`; - -exports[`text (isCI = false) > validation errors render and clear 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz -└ -", - "", - "", - "", - "◇ foo -│ xy", - " -", - "", -] -`; - -exports[`text (isCI = true) > can cancel 1`] = ` -[ - "", - "│ -◆ foo -│   -└ -", - "", - "", - "", - "■ foo -│", - " -", - "", -] -`; - -exports[`text (isCI = true) > initialValue sets the value 1`] = ` -[ - "", - "│ -◆ foo -│ bar  -└ -", - "", - "", - "", - "◇ foo -│ bar", - " -", - "", -] -`; - -exports[`text (isCI = true) > renders and apply () suggestion 1`] = ` -[ - "", - "│ -◆ foo -│ bar -└ -", - "", - "", - "", - "│ bar ", - "", - "", - "", - "", - "◇ foo -│ bar", - " -", - "", -] -`; - -exports[`text (isCI = true) > renders cancelled value if one set 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "■ foo -│ xy -│", - " -", - "", -] -`; - -exports[`text (isCI = true) > renders message 1`] = ` -[ - "", - "│ -◆ foo -│   -└ -", - "", - "", - "", - "◇ foo -│", - " -", - "", -] -`; - -exports[`text (isCI = true) > renders submitted value 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "◇ foo -│ xy", - " -", - "", -] -`; - -exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz -└ -", - "", - "", - "", - "◇ foo -│ xy", - " -", - "", -] -`; - -exports[`text (isCI = true) > validation errors render and clear 1`] = ` -[ - "", - "│ -◆ foo -│ xyz -└ -", - "", - "", - "", - "│ xyz", - "", - "", - "", - "", - "▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz -└ -", - "", - "", - "", - "◇ foo -│ xy", - " -", - "", -] -`; diff --git a/packages/prompts/test/path.test.ts b/packages/prompts/test/path.test.ts index c7f0e208..0e15ec34 100644 --- a/packages/prompts/test/path.test.ts +++ b/packages/prompts/test/path.test.ts @@ -31,6 +31,7 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { './hello/john.jpg': '4', './hello/jeanne.png': '5', './root.zip': '6', + './bar': '7', }, '/tmp' ); @@ -50,38 +51,39 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { input.emit('keypress', '', { name: 'return' }); - await result; + const value = await result; expect(output.buffer).toMatchSnapshot(); + expect(value).toBe('/tmp/bar'); }); - test('renders and apply () suggestion', async () => { + test('can cancel', async () => { const result = prompts.path({ message: 'foo', - root: '/tmp', + root: '/tmp/', input, output, }); - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'escape', { name: 'escape' }); const value = await result; + expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); - - expect(value).toBe('/tmp/foo'); }); - test('can cancel', async () => { + test('renders cancelled value if one set', async () => { const result = prompts.path({ message: 'foo', - root: '/tmp/', input, output, + root: '/tmp/', }); - input.emit('keypress', 'escape', { name: 'escape' }); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'escape' }); const value = await result; @@ -89,25 +91,25 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); - test('renders cancelled value if one set', async () => { + test('renders submitted value', async () => { const result = prompts.path({ message: 'foo', + root: '/tmp/', input, output, - root: '/tmp/', }); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'escape' }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', '', { name: 'return' }); const value = await result; - expect(prompts.isCancel(value)).toBe(true); + expect(value).toBe('/tmp/bar'); expect(output.buffer).toMatchSnapshot(); }); - test('renders submitted value', async () => { + test('cannot submit unknown value', async () => { const result = prompts.path({ message: 'foo', root: '/tmp/', @@ -115,13 +117,15 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { output, }); - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '_', { name: '_' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'h', ctrl: true }); + input.emit('keypress', 'b', { name: 'b' }); input.emit('keypress', '', { name: 'return' }); const value = await result; - expect(value).toBe('/tmp/xy'); + expect(value).toBe('/tmp/bar'); expect(output.buffer).toMatchSnapshot(); }); @@ -151,11 +155,13 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { output, }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'a', { name: 'a' }); + // to match `root.zip` input.emit('keypress', 'r', { name: 'r' }); input.emit('keypress', '', { name: 'return' }); + // delete what we had + input.emit('keypress', '', { name: 'h', ctrl: true }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); const value = await result; @@ -172,11 +178,13 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { output, }); - input.emit('keypress', 'b', { name: 'b' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'a', { name: 'a' }); + // to match `root.zip` input.emit('keypress', 'r', { name: 'r' }); input.emit('keypress', '', { name: 'return' }); + // delete what we had + input.emit('keypress', '', { name: 'h', ctrl: true }); + input.emit('keypress', 'b', { name: 'b' }); + input.emit('keypress', '', { name: 'return' }); const value = await result; diff --git a/packages/prompts/test/suggestion.test.ts b/packages/prompts/test/suggestion.test.ts deleted file mode 100644 index 37caf3ef..00000000 --- a/packages/prompts/test/suggestion.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as prompts from '../src/index.js'; -import { MockReadable, MockWritable } from './test-utils.js'; - -describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { - let originalCI: string | undefined; - let output: MockWritable; - let input: MockReadable; - - beforeAll(() => { - originalCI = process.env.CI; - process.env.CI = isCI; - }); - - afterAll(() => { - process.env.CI = originalCI; - }); - - beforeEach(() => { - output = new MockWritable(); - input = new MockReadable(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test('renders message', async () => { - const result = prompts.suggestion({ - message: 'foo', - input, - output, - suggest: () => [], - }); - - input.emit('keypress', '', { name: 'return' }); - - await result; - - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders and apply () suggestion', async () => { - const result = prompts.suggestion({ - message: 'foo', - suggest: () => ['bar'], - input, - output, - }); - - input.emit('keypress', '\t', { name: 'tab' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(output.buffer).toMatchSnapshot(); - - expect(value).toBe('bar'); - }); - - test('can cancel', async () => { - const result = prompts.suggestion({ - message: 'foo', - suggest: () => [], - input, - output, - }); - - input.emit('keypress', 'escape', { name: 'escape' }); - - const value = await result; - - expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders cancelled value if one set', async () => { - const result = prompts.suggestion({ - message: 'foo', - input, - output, - suggest: () => ['xyz'], - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'escape' }); - - const value = await result; - - expect(prompts.isCancel(value)).toBe(true); - expect(output.buffer).toMatchSnapshot(); - }); - - test('renders submitted value', async () => { - const result = prompts.suggestion({ - message: 'foo', - suggest: () => ['xyz'], - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('initialValue sets the value', async () => { - const result = prompts.suggestion({ - message: 'foo', - initialValue: 'bar', - suggest: () => [], - input, - output, - }); - - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('bar'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('validation errors render and clear', async () => { - const result = prompts.suggestion({ - message: 'foo', - suggest: () => ['xyz'], - validate: (val) => (val !== 'xy' ? 'should be xy' : undefined), - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); - - test('validation errors render and clear (using Error)', async () => { - const result = prompts.suggestion({ - message: 'foo', - suggest: () => ['xyz'], - validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined), - input, - output, - }); - - input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', '', { name: 'return' }); - input.emit('keypress', 'y', { name: 'y' }); - input.emit('keypress', '', { name: 'return' }); - - const value = await result; - - expect(value).toBe('xy'); - expect(output.buffer).toMatchSnapshot(); - }); -});