diff --git a/examples/basic/select-path.ts b/examples/basic/select-path.ts new file mode 100644 index 00000000..abc86fc9 --- /dev/null +++ b/examples/basic/select-path.ts @@ -0,0 +1,15 @@ +import * as p from '@clack/prompts'; + +(async () => { + const selectResult = await p.selectPath({ + message: 'Pick a file with select component:', + initialValue: process.cwd(), + onlyShowDir: false, + }); + if (p.isCancel(selectResult)) { + p.cancel('File selection canceled'); + process.exit(0); + } + + console.log({ selectResult }); +})(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db26c399..47d9fb83 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,5 +6,6 @@ export { default as Prompt, isCancel } from './prompts/prompt'; export type { State } from './prompts/prompt'; export { default as SelectPrompt } from './prompts/select'; export { default as SelectKeyPrompt } from './prompts/select-key'; +export { default as SelectPathPrompt } from './prompts/select-path'; export { default as TextPrompt } from './prompts/text'; export { block } from './utils'; diff --git a/packages/core/src/prompts/confirm.ts b/packages/core/src/prompts/confirm.ts index d2d5bbb3..642f32c9 100644 --- a/packages/core/src/prompts/confirm.ts +++ b/packages/core/src/prompts/confirm.ts @@ -1,5 +1,5 @@ import { cursor } from 'sisteransi'; -import Prompt, { PromptOptions } from './prompt'; +import Prompt, { type PromptOptions } from './prompt'; interface ConfirmOptions extends PromptOptions { active: string; diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index b5440539..745110ba 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -1,4 +1,4 @@ -import Prompt, { PromptOptions } from './prompt'; +import Prompt, { type PromptOptions } from './prompt'; interface GroupMultiSelectOptions extends PromptOptions> { diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index 911dab25..a38d1d38 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -1,4 +1,4 @@ -import Prompt, { PromptOptions } from './prompt'; +import Prompt, { type PromptOptions } from './prompt'; interface MultiSelectOptions extends PromptOptions> { options: T[]; diff --git a/packages/core/src/prompts/password.ts b/packages/core/src/prompts/password.ts index 4004f57b..453b05fd 100644 --- a/packages/core/src/prompts/password.ts +++ b/packages/core/src/prompts/password.ts @@ -1,5 +1,5 @@ import color from 'picocolors'; -import Prompt, { PromptOptions } from './prompt'; +import Prompt, { type PromptOptions } from './prompt'; interface PasswordOptions extends PromptOptions { mask?: string; diff --git a/packages/core/src/prompts/select-key.ts b/packages/core/src/prompts/select-key.ts index daacfbb0..9cc61017 100644 --- a/packages/core/src/prompts/select-key.ts +++ b/packages/core/src/prompts/select-key.ts @@ -1,4 +1,4 @@ -import Prompt, { PromptOptions } from './prompt'; +import Prompt, { type PromptOptions } from './prompt'; interface SelectKeyOptions extends PromptOptions> { options: T[]; diff --git a/packages/core/src/prompts/select-path.ts b/packages/core/src/prompts/select-path.ts new file mode 100644 index 00000000..f29080b7 --- /dev/null +++ b/packages/core/src/prompts/select-path.ts @@ -0,0 +1,163 @@ +import { readdirSync } from 'node:fs'; +import path from 'node:path'; +import Prompt, { type PromptOptions } from './prompt'; + +interface PathNode { + index: number; + depth: number; + path: string; + name: string; + parent: PathNode | undefined; + children: PathNode[] | undefined; +} + +export interface SelectPathOptions extends PromptOptions { + onlyShowDir?: boolean; +} + +export default class SelectPathPrompt extends Prompt { + public readonly onlyShowDir: boolean; + public root: PathNode; + public currentLayer: PathNode[]; + public currentOption: PathNode; + + public get options(): PathNode[] { + const options: PathNode[] = []; + + function traverse(node: PathNode) { + options.push(node); + const children = node.children ?? []; + for (const child of children) { + traverse(child); + } + } + + traverse(this.root); + + return options; + } + + public get cursor(): number { + return this.options.indexOf(this.currentOption); + } + + private _changeCursor(index: number): void { + const firstIndex = 0; + const lastIndex = this.currentLayer.length - 1; + const nextIndex = index > lastIndex ? firstIndex : index < firstIndex ? lastIndex : index; + this.currentOption = this.currentLayer[nextIndex]; + } + + private _search(value: string): void { + const search = value.normalize('NFC').toLowerCase(); + if (search) { + const foundOption = this.currentLayer.find((option) => + option.name.normalize('NFC').toLowerCase().startsWith(search) + ); + if (foundOption) { + this._changeCursor(foundOption.index); + } + } + } + + private _enterChildren(): void { + const children = + this.currentOption.children && this._mapDir(this.currentOption.path, this.currentOption); + this.currentOption.children = children; + if (children?.length) { + this.currentLayer = children; + this.currentOption = children[0]; + } + } + + private _exitChildren(): void { + if (this.currentOption.parent === undefined) { + const newRootPath = path.resolve(this.currentOption.path, '..'); + this.root = this._createRoot(newRootPath); + this.currentLayer = [this.root]; + this.currentOption = this.root; + } else if (this.currentOption.parent.path === this.root.path) { + this.currentLayer = [this.root]; + this.currentOption = this.root; + } else { + const prevChildren = this.currentOption.parent.parent?.children ?? []; + this.currentLayer = prevChildren; + this.currentOption = + prevChildren.find((child) => child.name === this.currentOption.parent?.name) ?? + prevChildren[0]; + this.currentOption.children = this.currentOption.children && []; + } + } + + private _mapDir(dirPath: string, parent?: PathNode): PathNode[] { + return readdirSync(dirPath, { withFileTypes: true }) + .map( + (item, index) => + ({ + index, + depth: parent ? parent.depth + 1 : 0, + parent, + path: path.resolve(dirPath, item.name), + name: item.name, + children: item.isDirectory() ? [] : undefined, + }) satisfies PathNode + ) + .filter((node) => { + return this.onlyShowDir ? !!node.children : true; + }); + } + + private _createRoot(path: string): PathNode { + const root: PathNode = { + index: 0, + depth: 0, + parent: undefined, + path: path, + name: path, + children: [], + }; + root.children = this._mapDir(path, root); + return root; + } + + constructor(opts: SelectPathOptions) { + super(opts, true); + + const cwd = opts.initialValue ?? process.cwd(); + this.root = this._createRoot(cwd); + const initialLayer = this.root.children!; + this.currentLayer = initialLayer; + this.currentOption = initialLayer[0]; + this.onlyShowDir = opts.onlyShowDir ?? false; + + this.on('key', () => { + this.value = this.value.replace(opts.initialValue, ''); + this._search(this.value); + }); + + this.on('cursor', (key) => { + switch (key) { + case 'up': + if (this.currentLayer.length > 1) { + this._changeCursor(this.currentOption.index - 1); + } + break; + case 'down': + if (this.currentLayer.length > 1) { + this._changeCursor(this.currentOption.index + 1); + } + break; + case 'left': + this._exitChildren(); + break; + case 'right': + this._enterChildren(); + break; + } + }); + + this.on('finalize', () => { + this.value = this.currentOption.path; + }); + } +} diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index 521764e2..c10627ab 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -1,4 +1,4 @@ -import Prompt, { PromptOptions } from './prompt'; +import Prompt, { type PromptOptions } from './prompt'; interface SelectOptions extends PromptOptions> { options: T[]; diff --git a/packages/core/src/prompts/text.ts b/packages/core/src/prompts/text.ts index 816533df..54f20562 100644 --- a/packages/core/src/prompts/text.ts +++ b/packages/core/src/prompts/text.ts @@ -1,5 +1,5 @@ import color from 'picocolors'; -import Prompt, { PromptOptions } from './prompt'; +import Prompt, { type PromptOptions } from './prompt'; export interface TextOptions extends PromptOptions { placeholder?: string; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index b6071bb5..973f8c04 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -6,9 +6,10 @@ import { MultiSelectPrompt, PasswordPrompt, SelectKeyPrompt, + SelectPathPrompt, SelectPrompt, - State, - TextPrompt + TextPrompt, + type State } from '@clack/core'; import isUnicodeSupported from 'is-unicode-supported'; import color from 'picocolors'; @@ -813,3 +814,83 @@ export const tasks = async (tasks: Task[]) => { s.stop(result || task.title); } }; + +interface PathNode { + index: number; + depth: number; + path: string; + name: string; + children: PathNode[] | undefined; +} + +interface SelectPathOptions { + message: string; + /** + * Starting absolute path + * @default process.cwd() // current working dir + */ + initialValue?: string; + validate?: (value: string) => string | void; + /** + * Exclude files from options + * @default false + */ + onlyShowDir?: boolean; + /** + * Limit the number of options that appears at once + */ + maxItems?: number; +} + +export const selectPath = (opts: SelectPathOptions) => { + return new SelectPathPrompt({ + initialValue: opts.initialValue, + onlyShowDir: opts.onlyShowDir, + validate: opts.validate, + render() { + const title = [color.gray(S_BAR), `${symbol(this.state)} ${opts.message}`].join('\n'); + + switch (this.state) { + case 'submit': + return [title, `${color.gray(S_BAR)} ${color.dim(this.value)}`].join('\n'); + case 'cancel': + return [ + title, + `${color.gray(S_BAR)} ${color.dim(color.strikethrough(this.value))}\n${color.gray( + S_BAR + )}`, + ].join('\n'); + case 'error': + return [ + title, + `${color.yellow(S_BAR)} ${this.value}`, + `${color.yellow(S_BAR_END)} ${color.yellow(this.error)}\n`, + ].join('\n'); + default: + return [ + title, + `${color.cyan(S_BAR)} ` + + limitOptions({ + cursor: this.cursor, + maxItems: opts.maxItems, + options: this.options.map((option) => { + return [ + ' '.repeat(option.depth), + option.depth === this.currentOption.depth && + option.index === this.currentOption.index + ? color.green(S_RADIO_ACTIVE) + : color.dim(S_RADIO_INACTIVE), + ' ', + option.name, + ' ', + option.children ? (option.children.length ? `v` : `>`) : undefined, + ].join(''); + }), + style: (option) => option, + }).join(`\n${color.cyan(S_BAR)} `), + color.cyan(S_BAR_END), + ].join('\n'); + } + }, + }).prompt(); +}; diff --git a/tsconfig.json b/tsconfig.json index 34c34d38..c6739c2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,15 @@ "noEmit": true, "module": "ESNext", "target": "ESNext", - "moduleResolution": "node", + "moduleResolution": "Bundler", + "moduleDetection": "force", "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "lib": ["ES2022"], "paths": { "@clack/core": ["./packages/core/src"] }