inquirer-file-selector
Version:
Inquirer file selector prompt.
344 lines (335 loc) • 12.3 kB
JavaScript
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 };