diff --git a/examples/test/index.ts b/examples/test/index.ts new file mode 100644 index 00000000..a51f74ef --- /dev/null +++ b/examples/test/index.ts @@ -0,0 +1,12 @@ +import * as p from '@clack/prompts'; + +async function main() { + console.clear(); + p.select({ + options: [{ value: 'basic', label: 'Basic' }], + message: 'Select an example to run.', + enableFilter: true, + }); +} + +main().catch(console.error); diff --git a/examples/test/package.json b/examples/test/package.json new file mode 100644 index 00000000..5f13c9a4 --- /dev/null +++ b/examples/test/package.json @@ -0,0 +1,17 @@ +{ + "name": "@example/test", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": { + "@clack/core": "workspace:*", + "@clack/prompts": "workspace:*", + "picocolors": "^1.0.0" + }, + "scripts": { + "start": "jiti ./index.ts" + }, + "devDependencies": { + "jiti": "^1.17.0" + } +} \ No newline at end of file diff --git a/examples/test/tsconfig.json b/examples/test/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/examples/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/core/package.json b/packages/core/package.json index b928edbc..3698a385 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,6 +57,7 @@ "sisteransi": "^1.0.5" }, "devDependencies": { - "wrap-ansi": "^8.1.0" + "wrap-ansi": "^8.1.0", + "fzf": "^0.5.2" } } diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 21ee0077..071eb70e 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -6,6 +6,7 @@ import { Readable, Writable } from 'node:stream'; import { WriteStream } from 'node:tty'; import { cursor, erase } from 'sisteransi'; import wrap from 'wrap-ansi'; +import { Fzf } from 'fzf'; function diffLines(a: string, b: string) { if (a === b) return; @@ -46,6 +47,7 @@ export interface PromptOptions { input?: Readable; output?: Writable; debug?: boolean; + enableFilter?: boolean; } export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; @@ -58,6 +60,7 @@ export default class Prompt { private _track: boolean = false; private _render: (context: Omit) => string | void; protected _cursor: number = 0; + private _filterKey = ''; public state: State = 'initial'; public value: any; @@ -136,7 +139,7 @@ export default class Prompt { arr.push({ cb, once: true }); this.subscribers.set(event, arr); } - public emit(event: string, ...data: any[]) { + public emit(event: string, ...data: T[]) { const cbs = this.subscribers.get(event) ?? []; const cleanup: (() => void)[] = []; for (const subscriber of cbs) { @@ -157,7 +160,12 @@ export default class Prompt { if (this.state === 'error') { this.state = 'active'; } - if (key?.name && !this._track && aliases.has(key.name)) { + if ( + key?.name && + !this._track && + /* disable moving aliases when enable filter */ + (this.opts.enableFilter ? false : aliases.has(key.name)) + ) { this.emit('cursor', aliases.get(key.name)); } if (key?.name && keys.has(key.name)) { @@ -252,4 +260,24 @@ export default class Prompt { } this._prevFrame = frame; } + + protected registerFilterer(list: string[]) { + if (this.opts.enableFilter) { + const fzf = new Fzf(list); + this.on('key', (key) => { + if (key === /* backspace */ '\x7F') { + if (this._filterKey.length) this._filterKey = this._filterKey.slice(0, -1); + if (!this._filterKey.length) this.emit('filterClear'); + } else if (key.length === 1 && key !== ' ') { + this._filterKey += key; + } + const filtered = fzf.find(this._filterKey); + this.emit('filter', filtered); + }); + } + } + + getFilterKey() { + return this._filterKey; + } } diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index 521764e2..c726cf53 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -1,10 +1,13 @@ import Prompt, { PromptOptions } from './prompt'; +import { type FzfResultItem } from 'fzf'; interface SelectOptions extends PromptOptions> { options: T[]; initialValue?: T['value']; + enableFilter?: boolean; } export default class SelectPrompt extends Prompt { + originalOptions: T[] = []; options: T[]; cursor: number = 0; @@ -19,7 +22,7 @@ export default class SelectPrompt extends Prompt { constructor(opts: SelectOptions) { super(opts, false); - this.options = opts.options; + this.originalOptions = this.options = opts.options; this.cursor = this.options.findIndex(({ value }) => value === opts.initialValue); if (this.cursor === -1) this.cursor = 0; this.changeValue(); @@ -37,5 +40,22 @@ export default class SelectPrompt extends Prompt { } this.changeValue(); }); + + // For filter + this.registerFilterer(this.options.map(({ value }) => value)); + this.on('filter', (filtered: FzfResultItem[]) => { + this.cursor = 0; + if (filtered.length) { + this.options = filtered.map( + ({ item }) => this.originalOptions.find(({ value }) => value === item)! + ); + } else { + this.options = []; + } + }); + this.on('filterClear', () => { + this.cursor = 0; + this.options = this.originalOptions; + }); } } diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 3b342dd5..a57346bb 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -178,6 +178,7 @@ export interface SelectOptions[], Value> { message: string; options: Options; initialValue?: Value; + enableFilter?: boolean; } export const select = [], Value>( @@ -200,9 +201,15 @@ export const select = [], Value>( return new SelectPrompt({ options: opts.options, initialValue: opts.initialValue, + enableFilter: opts.enableFilter, render() { const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const filterKey = this.getFilterKey().length + ? this.getFilterKey() + : color.gray('Type to filter...'); + const filterer = opts.enableFilter ? ` > ${filterKey}\n${color.cyan(S_BAR)} ` : ''; + switch (this.state) { case 'submit': return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor], 'selected')}`; @@ -212,7 +219,7 @@ export const select = [], Value>( 'cancelled' )}\n${color.gray(S_BAR)}`; default: { - return `${title}${color.cyan(S_BAR)} ${this.options + return `${title}${color.cyan(S_BAR)}${filterer}${this.options .map((option, i) => opt(option, i === this.cursor ? 'active' : 'inactive')) .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60721067..090be6f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,8 +46,22 @@ importers: devDependencies: jiti: 1.17.0 + examples/test: + specifiers: + '@clack/core': workspace:* + '@clack/prompts': workspace:* + jiti: ^1.17.0 + picocolors: ^1.0.0 + dependencies: + '@clack/core': link:../../packages/core + '@clack/prompts': link:../../packages/prompts + picocolors: 1.0.0 + devDependencies: + jiti: 1.17.1 + packages/core: specifiers: + fzf: ^0.5.2 picocolors: ^1.0.0 sisteransi: ^1.0.5 wrap-ansi: ^8.1.0 @@ -55,6 +69,7 @@ importers: picocolors: 1.0.0 sisteransi: 1.0.5 devDependencies: + fzf: 0.5.2 wrap-ansi: 8.1.0 packages/prompts: @@ -1491,6 +1506,10 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /fzf/0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + dev: true + /gensync/1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'}