clack-tree-select
Version:
Beautiful, interactive, searchable tree selection prompt for Clack CLI apps
626 lines (620 loc) • 20.3 kB
JavaScript
import { Prompt } from '@clack/core';
import color from 'picocolors';
import fs from 'node:fs';
import path from 'node:path';
import isUnicodeSupported from 'is-unicode-supported';
import { WriteStream } from 'node:tty';
class TreeSelectPrompt extends Prompt {
tree;
flatTree = [];
cursor = 0;
multiple;
config;
// Search state
searchQuery = "";
isSearching = false;
constructor(opts) {
super(opts, false);
this.tree = this.normalizeTree(opts.tree);
this.multiple = opts.multiple ?? true;
this.config = {
multiple: opts.multiple ?? true,
required: opts.required ?? false,
showHelp: opts.showHelp ?? true,
searchable: opts.searchable ?? true,
icons: {
directory: "\u{1F4C1}",
file: "\u{1F4C4}",
expanded: "\u25BC",
collapsed: "\u25B6",
...opts.icons
}
};
this.value = opts.initialValues || [];
this.rebuildFlatTree();
this.on("cursor", (key) => {
this.handleCursor(key);
});
this.on("key", (char, key) => {
if (key?.shift) {
switch (key.name) {
case "e":
this.toggleExpandAll();
return;
case "a":
this.toggleSelectAll();
return;
}
}
if (!this.config.searchable) return;
if (key?.name === "space") {
return;
}
if (key?.name === "backspace") {
if (this.isSearching && this.searchQuery.length > 0) {
this.searchQuery = this.searchQuery.slice(0, -1);
if (this.searchQuery.length === 0) {
this.isSearching = false;
}
this.cursor = 0;
}
return;
}
if (key?.name === "escape") {
if (this.isSearching) {
this.searchQuery = "";
this.isSearching = false;
this.cursor = 0;
}
return;
}
if (typeof char === "string" && char.length === 1 && !key?.ctrl && !key?.meta) {
if (char.trim().length === 0) return;
const code = char.charCodeAt(0);
if (code >= 32 && code <= 126) {
this.isSearching = true;
this.searchQuery = this.searchQuery + char;
this.cursor = 0;
}
}
});
}
normalizeTree(tree) {
return tree.map((item) => this.normalizeTreeItem(item));
}
normalizeTreeItem(item) {
if (typeof item === "string" || item !== null && typeof item === "object" && !("value" in item)) {
return {
value: item,
name: String(item),
open: false
};
}
const treeItem = item;
return {
...treeItem,
name: treeItem.name || String(treeItem.value),
open: treeItem.open || false,
children: treeItem.children ? treeItem.children.map((child) => this.normalizeTreeItem(child)) : void 0
};
}
rebuildFlatTree() {
this.flatTree = [];
const flatten = (items, level = 0, parent) => {
for (const item of items) {
const flatItem = {
value: item.value,
name: item.name || String(item.value),
level,
isDirectory: Boolean(item.children),
isOpen: item.open || false,
parent,
originalItem: item
};
this.flatTree.push(flatItem);
if (item.children && item.open) {
flatten(item.children, level + 1, flatItem);
}
}
};
flatten(this.tree);
}
/** Build a flattened list of the entire tree ignoring open/closed state */
buildFullFlatTree() {
const result = [];
const flattenAll = (items, level = 0, parent) => {
for (const item of items) {
const flatItem = {
value: item.value,
name: item.name || String(item.value),
level,
isDirectory: Boolean(item.children),
isOpen: item.open || false,
parent,
originalItem: item
};
result.push(flatItem);
if (item.children) {
flattenAll(item.children, level + 1, flatItem);
}
}
};
flattenAll(this.tree);
return result;
}
/** Return the list of items currently visible (search-filtered or by open state) */
getVisibleFlatTree() {
const qRaw = this.searchQuery ?? "";
const q = qRaw.replace(/\s+/g, "").toLowerCase();
if (!q) return this.flatTree;
return this.buildFullFlatTree().filter((it) => this.isFuzzyMatch(it.name || "", q));
}
/** Fuzzy match: query (spaces removed) must appear in order within name (spaces removed) */
isFuzzyMatch(name, queryNoSpacesLower) {
const target = (name || "").replace(/\s+/g, "").toLowerCase();
if (!queryNoSpacesLower) return true;
let ti = 0;
for (let qi = 0; qi < queryNoSpacesLower.length; qi++) {
const qc = queryNoSpacesLower[qi];
ti = target.indexOf(qc, ti);
if (ti === -1) return false;
ti += 1;
}
return true;
}
toggleDirectory(targetValue) {
const findAndToggle = (items) => {
for (const item of items) {
if (item.value === targetValue && item.children) {
item.open = !item.open;
return true;
}
if (item.children && findAndToggle(item.children)) {
return true;
}
}
return false;
};
if (findAndToggle(this.tree)) {
this.rebuildFlatTree();
}
}
toggleSelection(targetValue) {
if (!this.value) this.value = [];
const index = this.value.indexOf(targetValue);
const isCurrentlySelected = index !== -1;
const findItem = (items) => {
for (const item of items) {
if (item.value === targetValue) return item;
if (item.children) {
const found = findItem(item.children);
if (found) return found;
}
}
return null;
};
const targetItem = findItem(this.tree);
if (!this.multiple && targetItem?.children) {
return;
}
if (isCurrentlySelected) {
this.value = this.value.filter((v) => v !== targetValue);
if (targetItem?.children) {
this.deselectChildren(targetItem.children);
}
} else {
if (this.multiple) {
this.value = [...this.value, targetValue];
if (targetItem?.children) {
this.selectChildren(targetItem.children);
}
} else {
this.value = [targetValue];
}
}
}
selectChildren(children) {
if (!this.value) this.value = [];
for (const child of children) {
if (!this.value.includes(child.value)) {
this.value.push(child.value);
}
if (child.children) {
this.selectChildren(child.children);
}
}
}
deselectChildren(children) {
if (!this.value) this.value = [];
for (const child of children) {
this.value = this.value.filter((v) => v !== child.value);
if (child.children) {
this.deselectChildren(child.children);
}
}
}
expandAll() {
const expandRecursive = (items) => {
for (const item of items) {
if (item.children) {
item.open = true;
expandRecursive(item.children);
}
}
};
expandRecursive(this.tree);
this.rebuildFlatTree();
}
collapseAll() {
const collapseRecursive = (items) => {
for (const item of items) {
if (item.children) {
item.open = false;
collapseRecursive(item.children);
}
}
};
collapseRecursive(this.tree);
this.rebuildFlatTree();
}
selectAll() {
if (!this.multiple) return;
const getAllValues = (items) => {
const values = [];
for (const item of items) {
values.push(item.value);
if (item.children) {
values.push(...getAllValues(item.children));
}
}
return values;
};
this.value = getAllValues(this.tree);
}
deselectAll() {
this.value = [];
}
areAllDirectoriesExpanded() {
const checkExpanded = (items) => {
for (const item of items) {
if (item.children) {
if (!item.open) return false;
if (!checkExpanded(item.children)) return false;
}
}
return true;
};
return checkExpanded(this.tree);
}
areAllItemsSelected() {
if (!this.multiple) return false;
const getAllValues = (items) => {
const values = [];
for (const item of items) {
values.push(item.value);
if (item.children) {
values.push(...getAllValues(item.children));
}
}
return values;
};
const allValues = getAllValues(this.tree);
const current = this.value ?? [];
return allValues.length === current.length && allValues.every((val) => current.includes(val));
}
toggleExpandAll() {
if (this.areAllDirectoriesExpanded()) {
this.collapseAll();
} else {
this.expandAll();
}
}
toggleSelectAll() {
if (!this.multiple) return;
if (this.areAllItemsSelected()) {
this.deselectAll();
} else {
this.selectAll();
}
}
handleCursor(key) {
if (this.state !== "active") return;
if (!key) return;
const visible = this.getVisibleFlatTree();
const currentItem = visible[this.cursor];
switch (key) {
case "up":
this.cursor = Math.max(0, this.cursor - 1);
break;
case "down":
this.cursor = Math.min(Math.max(visible.length - 1, 0), this.cursor + 1);
break;
case "right":
if (currentItem?.isDirectory && !currentItem.isOpen) {
this.toggleDirectory(currentItem.value);
}
break;
case "left":
if (currentItem?.isDirectory && currentItem.isOpen) {
this.toggleDirectory(currentItem.value);
}
break;
case "space":
if (currentItem) {
if (!this.multiple && currentItem.isDirectory) {
return;
}
this.toggleSelection(currentItem.value);
}
break;
}
}
getState() {
return {
value: this.value,
cursor: this.cursor,
state: this.state,
flatTree: this.flatTree,
searchQuery: this.searchQuery,
isSearching: this.isSearching
};
}
/**
* Start the prompt and return a Promise that resolves with the selected values or cancel symbol
*/
}
const unicode = isUnicodeSupported();
const unicodeOr = (c, fallback) => unicode ? c : fallback;
const S_STEP_ACTIVE = unicodeOr("\u25C6", "*");
const S_STEP_CANCEL = unicodeOr("\u25A0", "x");
const S_STEP_ERROR = unicodeOr("\u25B2", "x");
const S_STEP_SUBMIT = unicodeOr("\u25C7", "o");
const S_BAR = unicodeOr("\u2502", "|");
const S_BAR_END = unicodeOr("\u2514", "\u2014");
const S_CHECKBOX_ACTIVE = unicodeOr("\u25FB", "[\u2022]");
const S_CHECKBOX_SELECTED = unicodeOr("\u25FC", "[+]");
const S_CHECKBOX_INACTIVE = unicodeOr("\u25FB", "[ ]");
const symbol = (state) => {
switch (state) {
case "initial":
case "active":
return color.cyan(S_STEP_ACTIVE);
case "cancel":
return color.red(S_STEP_CANCEL);
case "error":
return color.yellow(S_STEP_ERROR);
case "submit":
return color.green(S_STEP_SUBMIT);
}
};
const limitOptions = (params) => {
const { cursor, options, style } = params;
const output = params.output ?? process.stdout;
const rows = output instanceof WriteStream && output.rows !== void 0 ? output.rows : 10;
const overflowFormat = color.dim("...");
const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY;
const outputMaxItems = Math.max(rows - 4, 0);
const maxItems = Math.min(outputMaxItems, Math.max(paramMaxItems, 5));
let slidingWindowLocation = 0;
if (cursor >= slidingWindowLocation + maxItems - 3) {
slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0);
} else if (cursor < slidingWindowLocation + 2) {
slidingWindowLocation = Math.max(cursor - 2, 0);
}
const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
const shouldRenderBottomEllipsis = maxItems < options.length && slidingWindowLocation + maxItems < options.length;
return options.slice(slidingWindowLocation, slidingWindowLocation + maxItems).map((option, i, arr) => {
const isTopLimit = i === 0 && shouldRenderTopEllipsis;
const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis;
return isTopLimit || isBottomLimit ? overflowFormat : style(option, i + slidingWindowLocation === cursor);
});
};
function buildFileSystemTree(rootPath, options = {}) {
const {
includeFiles = true,
includeHidden = false,
maxDepth = Infinity,
filter,
currentDepth = 0
} = options;
if (currentDepth >= maxDepth) {
return [];
}
try {
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
const tree = [];
for (const entry of entries) {
if (!includeHidden && entry.name.startsWith(".")) {
continue;
}
const fullPath = path.join(rootPath, entry.name);
if (filter && !filter(fullPath)) {
continue;
}
if (entry.isDirectory()) {
const children = buildFileSystemTree(fullPath, {
...options,
currentDepth: currentDepth + 1
});
tree.push({
value: fullPath,
name: entry.name,
open: false,
children
});
} else if (includeFiles) {
tree.push({
value: fullPath,
name: entry.name,
open: false
});
}
}
return tree.sort((a, b) => {
const aIsDir = Boolean(a.children);
const bIsDir = Boolean(b.children);
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return (a.name || "").localeCompare(b.name || "");
});
} catch (error) {
console.warn(`Warning: Could not read directory ${rootPath}:`, error);
return [];
}
}
const fileSystemTreeSelect = (opts) => {
const tree = buildFileSystemTree(opts.root, {
includeFiles: opts.includeFiles,
includeHidden: opts.includeHidden,
maxDepth: opts.maxDepth,
filter: opts.filter
});
return treeSelect({
...opts,
tree
});
};
const treeSelect = (opts) => {
const defaultOptions = {
multiple: true,
required: false,
searchable: true,
maxDepth: Infinity,
showHelp: true,
icons: {
directory: opts.icons?.directory || "\u{1F4C1}",
file: opts.icons?.file || "\u{1F4C4}",
expanded: opts.icons?.expanded || "\u25BC",
collapsed: opts.icons?.collapsed || "\u25B6"
}
};
const config = { ...defaultOptions, ...opts };
const renderTreeItem = (item, state) => {
const indent = " ".repeat(item.level);
const icon = item.isDirectory ? config.icons.directory : config.icons.file;
const dirIndicator = item.isDirectory ? item.isOpen ? `${config.icons.expanded} ` : `${config.icons.collapsed} ` : "";
const name = item.name;
const isDirectoryInSingleMode = !config.multiple && item.isDirectory;
if (state === "active") {
if (isDirectoryInSingleMode) {
return `${indent}${color.dim("\u25CB")} ${dirIndicator}${icon} ${color.cyan(name)}`;
}
return `${indent}${color.cyan(S_CHECKBOX_ACTIVE)} ${dirIndicator}${icon} ${color.cyan(name)}`;
}
if (state === "selected") {
return `${indent}${color.green(S_CHECKBOX_SELECTED)} ${dirIndicator}${icon} ${color.dim(name)}`;
}
if (state === "cancelled") {
return `${indent}${color.strikethrough(color.dim(`${dirIndicator}${icon} ${name}`))}`;
}
if (state === "active-selected") {
return `${indent}${color.green(S_CHECKBOX_SELECTED)} ${dirIndicator}${icon} ${color.cyan(name)}`;
}
if (state === "submitted") {
return `${indent}${color.dim(`${dirIndicator}${icon} ${name}`)}`;
}
if (isDirectoryInSingleMode) {
return `${indent}${color.dim("\u25CB")} ${color.dim(`${dirIndicator}${icon} ${name}`)}`;
}
return `${indent}${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(`${dirIndicator}${icon} ${name}`)}`;
};
const prompt = new TreeSelectPrompt({
...config,
tree: config.tree,
validate(selected) {
if (config.required && (selected === void 0 || selected.length === 0)) {
const helpText = config.showHelp ? `
${color.reset(
color.dim(
`Shortcuts: ${color.gray(color.bgWhite(color.inverse(" space ")))} select, ${color.gray(color.bgWhite(color.inverse(" \u2192 ")))} expand, ${color.gray(color.bgWhite(color.inverse(" \u2190 ")))} collapse, ${color.gray(color.bgWhite(color.inverse(" shift+e ")))} toggle expand all, ${color.gray(color.bgWhite(color.inverse(" shift+a ")))} toggle select all, ${color.gray(color.bgWhite(color.inverse(" enter ")))} submit` + (config.searchable ? `, ${color.gray(color.bgWhite(color.inverse(" type ")))} search, ${color.gray(color.bgWhite(color.inverse(" esc ")))} clear` : "")
)
)}` : "";
return `Please select at least one option.${helpText}`;
}
},
render() {
const title = `${color.gray(S_BAR)}
${symbol(this.state)} ${opts.message}
`;
const value = this.value ?? [];
const baseItems = this.getVisibleFlatTree ? this.getVisibleFlatTree() : this.flatTree || [];
const searchText = this.searchQuery ?? "";
const normalized = searchText.replace(/\s+/g, "").toLowerCase();
const isFuzzyMatch = (name, q) => {
if (!q) return true;
const target = (name || "").replace(/\s+/g, "").toLowerCase();
let ti = 0;
for (let qi = 0; qi < q.length; qi++) {
const qc = q[qi];
ti = target.indexOf(qc, ti);
if (ti === -1) return false;
ti += 1;
}
return true;
};
const visibleItems = normalized ? baseItems.filter((it) => isFuzzyMatch(it.name, normalized)) : baseItems;
const searching = this.isSearching ?? false;
const searchQuery = this.searchQuery ?? "";
const styleOption = (item, active) => {
const selected = value.includes(item.value);
if (active && selected) {
return renderTreeItem(item, "active-selected");
}
if (selected) {
return renderTreeItem(item, "selected");
}
return renderTreeItem(item, active ? "active" : "inactive");
};
const searchLine = config.searchable && (searching || searchQuery) ? `${color.gray(S_BAR)} ${color.dim("Search: ")}${searchQuery || ""}
` : "";
switch (this.state) {
case "submit": {
const selectedItems = visibleItems.filter((item) => value.includes(item.value)).map((item) => renderTreeItem(item, "submitted")).join(color.dim(", "));
return `${title}${searchLine}${color.gray(S_BAR)} ${selectedItems || color.dim("none")}`;
}
case "cancel": {
const selectedItems = visibleItems.filter((item) => value.includes(item.value)).map((item) => renderTreeItem(item, "cancelled")).join(color.dim(", "));
return `${title}${searchLine}${color.gray(S_BAR)}${selectedItems.trim() ? ` ${selectedItems}
${color.gray(S_BAR)}` : ""}`;
}
case "error": {
const footer = String(this.error).split("\n").map(
(ln, i) => i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`
).join("\n");
return `${title}${searchLine}${color.yellow(S_BAR)} ${limitOptions({
output: opts.output,
options: visibleItems,
cursor: this.cursor,
maxItems: opts.maxItems,
style: styleOption
}).join(`
${color.yellow(S_BAR)} `)}
${footer}
`;
}
default: {
return `${title}${searchLine}${color.cyan(S_BAR)} ${limitOptions({
output: opts.output,
options: visibleItems,
cursor: this.cursor,
maxItems: opts.maxItems,
style: styleOption
}).join(`
${color.cyan(S_BAR)} `)}
${color.cyan(S_BAR_END)}
`;
}
}
}
});
return new Promise((resolve) => {
prompt.on("submit", (value) => {
resolve(value);
});
prompt.on("cancel", (value) => {
resolve(value);
});
prompt.prompt();
});
};
export { TreeSelectPrompt, buildFileSystemTree, fileSystemTreeSelect, treeSelect };