feat(prompts): add directory prompt with autocomplete and create-directory support
This commit is contained in:
parent
d5c314d84b
commit
a830448651
|
|
@ -10,6 +10,8 @@
|
||||||
let _clack = null;
|
let _clack = null;
|
||||||
let _clackCore = null;
|
let _clackCore = null;
|
||||||
let _picocolors = null;
|
let _picocolors = null;
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy-load @clack/prompts (ESM module)
|
* Lazy-load @clack/prompts (ESM module)
|
||||||
|
|
@ -575,6 +577,137 @@ async function autocomplete(options) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPathSeparator(value) {
|
||||||
|
return value.endsWith('/') || value.endsWith('\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDirectoryOption(value, label = value, synthetic = false) {
|
||||||
|
return { value, label, synthetic };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExistingDirectory(value) {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(value) && fs.statSync(value).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDirectoryOptions(input, options) {
|
||||||
|
const cwd = options.cwd || process.cwd();
|
||||||
|
const rawInput = input.trim();
|
||||||
|
const resolvedInput = rawInput ? path.resolve(cwd, rawInput) : cwd;
|
||||||
|
const browseDir =
|
||||||
|
rawInput && !hasPathSeparator(rawInput) && !isExistingDirectory(resolvedInput) ? path.dirname(resolvedInput) : resolvedInput;
|
||||||
|
const prefix = rawInput && browseDir !== resolvedInput ? path.basename(resolvedInput).toLowerCase() : '';
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
if (!hasPathSeparator(rawInput) && isExistingDirectory(resolvedInput)) {
|
||||||
|
results.push(toDirectoryOption(resolvedInput));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExistingDirectory(browseDir)) {
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(browseDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
if (prefix && !entry.name.toLowerCase().startsWith(prefix)) continue;
|
||||||
|
const fullPath = path.join(browseDir, entry.name);
|
||||||
|
if (!results.some((option) => option.value === fullPath)) {
|
||||||
|
results.push(toDirectoryOption(fullPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable directories; validation still reports path issues.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = options.validate?.(rawInput);
|
||||||
|
const hasMatchingOption = results.some((option) => option.value === rawInput || option.value === resolvedInput);
|
||||||
|
if (rawInput && !validation && !hasMatchingOption) {
|
||||||
|
results.unshift(toDirectoryOption(rawInput, `Create/use: ${rawInput}`, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory prompt with autocomplete candidates and create-directory support.
|
||||||
|
* Uses @clack/core directly so typed paths that do not exist yet can still be
|
||||||
|
* submitted when validation allows creating them.
|
||||||
|
* @param {Object} options - Prompt options
|
||||||
|
* @param {string} options.message - Prompt message
|
||||||
|
* @param {string} [options.default] - Default directory
|
||||||
|
* @param {string} [options.placeholder] - Placeholder text
|
||||||
|
* @param {Function} [options.validate] - Sync validation function
|
||||||
|
* @returns {Promise<string>} Selected or typed directory path
|
||||||
|
*/
|
||||||
|
async function directory(options) {
|
||||||
|
const core = await getClackCore();
|
||||||
|
const color = await getPicocolors();
|
||||||
|
const tabCompletion = {
|
||||||
|
prefix: '',
|
||||||
|
index: -1,
|
||||||
|
options: [],
|
||||||
|
lastValue: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt;
|
||||||
|
prompt = new core.AutocompletePrompt({
|
||||||
|
initialValue: options.default,
|
||||||
|
options: () => listDirectoryOptions(prompt?.userInput || '', options),
|
||||||
|
filter: () => true,
|
||||||
|
validate: (value) => options.validate?.(value ?? prompt.userInput),
|
||||||
|
render() {
|
||||||
|
const title = `${color.gray('◆')} ${options.message}`;
|
||||||
|
const bar = color.gray('│');
|
||||||
|
const barEnd = color.gray('└');
|
||||||
|
const userInput = this.userInput;
|
||||||
|
const placeholder = options.placeholder || options.default;
|
||||||
|
const inputDisplay = userInput ? this.userInputWithCursor : `${color.inverse(color.hidden('_'))}${color.dim(placeholder || '')}`;
|
||||||
|
const errorLine = this.state === 'error' ? [`${color.yellow('│')} ${color.yellow(this.error)}`] : [];
|
||||||
|
|
||||||
|
switch (this.state) {
|
||||||
|
case 'submit': {
|
||||||
|
return `${color.gray('◇')} ${options.message}\n${bar} ${color.dim(this.value || '')}`;
|
||||||
|
}
|
||||||
|
case 'cancel': {
|
||||||
|
return `${color.gray('◇')} ${options.message}\n${bar} ${color.strikethrough(color.dim(userInput || ''))}`;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return [title, `${bar} ${inputDisplay}`, ...errorLine, barEnd].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
prompt.on('key', (_, key) => {
|
||||||
|
if (key?.name !== 'tab') return;
|
||||||
|
const currentInput = prompt.userInput;
|
||||||
|
const isContinuingCycle = tabCompletion.lastValue && currentInput === tabCompletion.lastValue;
|
||||||
|
const completionOptions = isContinuingCycle ? tabCompletion.options : prompt.filteredOptions.filter((option) => !option.synthetic);
|
||||||
|
if (completionOptions.length === 0) return;
|
||||||
|
|
||||||
|
if (isContinuingCycle) {
|
||||||
|
tabCompletion.index = (tabCompletion.index + 1) % completionOptions.length;
|
||||||
|
} else {
|
||||||
|
tabCompletion.prefix = currentInput;
|
||||||
|
tabCompletion.options = completionOptions;
|
||||||
|
tabCompletion.index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusedOption = completionOptions[tabCompletion.index];
|
||||||
|
if (!focusedOption) return;
|
||||||
|
const completedValue = focusedOption.value;
|
||||||
|
tabCompletion.lastValue = completedValue;
|
||||||
|
prompt._clearUserInput();
|
||||||
|
prompt._setUserInput(completedValue, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await prompt.prompt();
|
||||||
|
await handleCancel(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the color utility (picocolors instance from @clack/prompts)
|
* Get the color utility (picocolors instance from @clack/prompts)
|
||||||
* @returns {Promise<Object>} The color utility (picocolors)
|
* @returns {Promise<Object>} The color utility (picocolors)
|
||||||
|
|
@ -694,6 +827,7 @@ module.exports = {
|
||||||
multiselect,
|
multiselect,
|
||||||
autocompleteMultiselect,
|
autocompleteMultiselect,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
|
directory,
|
||||||
confirm,
|
confirm,
|
||||||
text,
|
text,
|
||||||
password,
|
password,
|
||||||
|
|
|
||||||
|
|
@ -1436,7 +1436,7 @@ class UI {
|
||||||
*/
|
*/
|
||||||
async promptForDirectory() {
|
async promptForDirectory() {
|
||||||
// Use sync validation because @clack/prompts doesn't support async validate
|
// Use sync validation because @clack/prompts doesn't support async validate
|
||||||
const directory = await prompts.text({
|
const directory = await prompts.directory({
|
||||||
message: 'Installation directory:',
|
message: 'Installation directory:',
|
||||||
default: process.cwd(),
|
default: process.cwd(),
|
||||||
placeholder: process.cwd(),
|
placeholder: process.cwd(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue