UNPKG

inquirer-file-selector

Version:
344 lines (335 loc) 12.3 kB
import path, { join, basename, sep } from 'node:path'; import { createPrompt, useState, useMemo, makeTheme, usePrefix, useRef, useKeypress, usePagination } from '@inquirer/core'; import { styleText } from 'node:util'; import figures from '@inquirer/figures'; import { readdirSync, statSync, accessSync, constants } from 'node:fs'; const ANSI_HIDE_CURSOR = '\x1B[?25l'; const Status = { Idle: 'idle', Done: 'done', Canceled: 'canceled' }; const ItemType = { File: 'file', Directory: 'directory' }; const defaultKeybinds = { up: ['up', 'w'], down: ['down', 's'], back: ['left', 'a'], forward: ['right', 'd'], toggle: ['space'], confirm: ['enter', 'return'], cancel: ['escape'] }; function ensurePathSeparator(path) { return path.endsWith(sep) ? path : `${path}${sep}`; } function createRawItem(path) { const stats = statSync(path); const name = basename(path); const isDirectory = stats.isDirectory(); const displayName = isDirectory ? ensurePathSeparator(name) : name; isDirectory && accessSync(path, constants.R_OK); return { displayName, name, path, size: stats.size, createdMs: stats.birthtimeMs, lastModifiedMs: stats.mtimeMs, isDirectory, isCwd: false, isSelected: false }; } function readRawItems(path) { return readdirSync(path) .map(fileName => { const filePath = join(path, fileName); try { return createRawItem(filePath); } catch { return null; } }) .filter(item => item !== null); } function sortRawItems(items) { items.sort((a, b) => { const aPriority = a.isDirectory ? -1 : 0; const bPriority = b.isDirectory ? -1 : 0; if (aPriority !== bPriority) { return aPriority - bPriority; } return a.name.localeCompare(b.name); }); } function stripInternalProps(raw) { const { displayName, isCwd, isSelected, ...item } = raw; return item; } function isValidItemType(item, type) { return (!type || (type === ItemType.File && !item.isDirectory) || (type === ItemType.Directory && item.isDirectory)); } const baseTheme = { prefix: { idle: styleText('cyan', '?'), done: styleText('green', figures.tick), canceled: styleText('red', figures.cross) }, style: { active: text => styleText('cyan', text), directory: text => styleText('yellowBright', text), file: text => text, currentDir: text => styleText('magentaBright', text), message: (text, _status) => styleText('bold', text), help: text => styleText('gray', text), key: text => styleText(['bgGray', 'white'], ` ${text} `), messages: { cancel: text => styleText('red', text), empty: text => styleText('red', text) } }, hierarchySymbols: { branch: figures.lineUpDownRight + figures.line, leaf: figures.lineUpRight + figures.line }, labels: { keys: { up: `${figures.arrowUp}/w`, down: `${figures.arrowDown}/s`, back: `${figures.arrowLeft}/a`, forward: `${figures.arrowRight}/d`, toggle: '\u2423', confirm: '\u21B5', cancel: 'Esc' }, hints: { navigate: '{{up}} or {{down}} to navigate', goBack: '{{back}} to go back', goForward: '{{forward}} to open', toggle: '{{toggle}} to select', confirm: '{{confirm}} to confirm', cancel: '{{cancel}} to cancel' }, messages: { cancel: 'Canceled.', empty: 'Directory is empty.' } }, renderHelp({ type, context }) { const hints = []; if (type === 'header') { hints.push(this.labels.hints.navigate); hints.push(this.labels.hints.goBack); context.multiple && hints.push(this.labels.hints.confirm); context.allowCancel && hints.push(this.labels.hints.cancel); } else if (type === 'inline') { if (!context.item.isCwd && context.item.isDirectory) { hints.push(this.labels.hints.goForward); } if (isValidItemType(context.item, context.type)) { context.multiple ? hints.push(this.labels.hints.toggle) : hints.push(this.labels.hints.confirm); } } return hints.length ? this.style.help(`(Press ${hints.join(', ')})`) : ''; }, renderItem(item, context) { const { items, type, multiple, loop, index, isActive } = context; const isLastItem = index === items.length - 1; const linePrefixKey = isLastItem && !loop ? 'leaf' : 'branch'; const linePrefix = this.hierarchySymbols[linePrefixKey]; const baseColor = item.isDirectory ? this.style.directory : this.style.file; const color = isActive ? this.style.active : baseColor; let line = color(`${linePrefix} ${item.displayName}`); if (multiple) { if (item.isSelected) { line += ` ${figures.radioOn}`; } else if (isActive && isValidItemType(item, type)) { line += ` ${figures.radioOff}`; } } if (isActive) { const helpMessage = this.renderHelp({ type: 'inline', context: { type, multiple, item } }); line += ` ${helpMessage}`; } return line; } }; function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } function createActionChecks(defaultKeybinds, keybinds) { const mergedKeybinds = { ...defaultKeybinds, ...keybinds }; const checks = {}; for (const [action, keys] of Object.entries(mergedKeybinds)) { const methodName = `is${capitalize(action)}`; checks[methodName] = event => keys.includes(event.name); } return checks; } function prepareTheme(theme) { const style = theme.style; const keys = theme.labels.keys; const hints = theme.labels.hints; const messages = theme.labels.messages; for (const [key, value] of Object.entries(keys)) { const keyTyped = key; keys[keyTyped] = style.key(value); } for (const [hintKey, hintValue] of Object.entries(hints)) { const hintKeyTyped = hintKey; hints[hintKeyTyped] = hintValue.replace(/\{\{(\w+)\}\}/g, (match, key) => { if (Object.hasOwn(keys, key)) { const keyTyped = key; return keys[keyTyped]; } else { return match; } }); } messages.cancel = style.messages.cancel(messages.cancel); messages.empty = style.messages.empty(messages.empty); } function fileSelector(config) { return createPrompt((config, done) => { const { multiple = false, pageSize = 10, loop = false, filter = () => true, allowCancel = false, allowBack = () => true } = config; const [status, setStatus] = useState(Status.Idle); const action = useMemo(() => { return createActionChecks(defaultKeybinds, config.keybinds); }, []); const theme = useMemo(() => { const t = makeTheme(baseTheme, config.theme); prepareTheme(t); return t; }, []); const prefix = usePrefix({ status, theme }); const selections = useRef(new Map()); const [currentDir, setCurrentDir] = useState(path.resolve(process.cwd(), config.basePath || '.')); const backDir = useMemo(() => path.resolve(currentDir, '..'), [currentDir]); const canGoBack = useMemo(() => allowBack(backDir), [backDir]); const items = useMemo(() => { const rawItems = readRawItems(currentDir).filter(rawItem => { const strippedItem = stripInternalProps(rawItem); return filter(strippedItem); }); sortRawItems(rawItems); if (config.type !== ItemType.File) { const cwd = createRawItem(currentDir); cwd.displayName = ensurePathSeparator('.'); cwd.isCwd = true; rawItems.unshift(cwd); } if (multiple) { return rawItems.map(rawItem => ({ ...rawItem, isSelected: selections.current.has(rawItem.path) })); } return rawItems; }, [currentDir]); const bounds = useMemo(() => { const first = items.length > 0 ? 0 : -1; const last = items.length > 0 ? items.length - 1 : -1; return { first, last }; }, [items]); const [active, setActive] = useState(bounds.first); const activeItem = items[active]; useKeypress(key => { if (action.isUp(key) || action.isDown(key)) { if (!loop && action.isUp(key) && active === bounds.first) return; if (!loop && action.isDown(key) && active === bounds.last) return; const offset = action.isUp(key) ? -1 : 1; const next = (active + offset + items.length) % items.length; setActive(next); } else if (action.isBack(key)) { if (!canGoBack) return; setCurrentDir(backDir); setActive(bounds.first); } else if (action.isForward(key)) { if (!activeItem.isDirectory) return; setCurrentDir(activeItem.path); setActive(bounds.first); } else if (action.isToggle(key)) { if (!multiple) return; if (!isValidItemType(activeItem, config.type)) return; activeItem.isSelected = !activeItem.isSelected; activeItem.isSelected ? selections.current.set(activeItem.path, activeItem) : selections.current.delete(activeItem.path); setActive(active - 1); setActive(active); } else if (action.isConfirm(key)) { if (!activeItem) return; let result = null; if (multiple) { result = Array.from(selections.current.values(), stripInternalProps); } else { if (!isValidItemType(activeItem, config.type)) return; result = stripInternalProps(activeItem); } setStatus(Status.Done); done(result); } else if (action.isCancel(key)) { if (!allowCancel) return; setStatus(Status.Canceled); done(null); } }); const page = usePagination({ items, active, renderItem: ({ item, index, isActive }) => theme.renderItem(item, { items, type: config.type, multiple, loop, index, isActive }), pageSize, loop }); const message = theme.style.message(config.message, status); if (status === Status.Canceled) { return `${prefix} ${message} ${theme.labels.messages.cancel}`; } if (status === Status.Done) { return `${prefix} ${message} ${theme.style.answer(activeItem.path)}`; } const helpTop = theme.renderHelp({ type: 'header', context: { allowCancel, multiple } }); const header = theme.style.currentDir(ensurePathSeparator(currentDir)); return `${prefix} ${message} ${helpTop}\n${header}\n${!page.length ? theme.labels.messages.empty : page}${ANSI_HIDE_CURSOR}`; })(config); } export { ItemType, Status, fileSelector };