UNPKG

clack-tree-select

Version:

Beautiful, interactive, searchable tree selection prompt for Clack CLI apps

626 lines (620 loc) 20.3 kB
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 };