[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

feat(core): add search

Simon He (Nov 14, 2024, 2:57 PM +0800) ecde766e 6679f192

+154 -106
+60 -34
packages/core/src/prompts/search.ts
··· 1 1 import Prompt, { PromptOptions } from './prompt'; 2 - import color from 'picocolors'; 3 - import fuzzy from 'fuzzy' 4 - import { SearchPrompt } from '..'; 5 - interface SearchOptions<T extends { value: any, label?: string }> extends PromptOptions<SearchPrompt<T>> { 2 + import fuzzy from 'fuzzy'; 3 + interface SearchOptions<T extends { value: any; label?: string }> 4 + extends PromptOptions<SearchPrompt<T>> { 6 5 options: T[]; 7 6 initialValue?: T['value']; 8 7 maxItems?: number; 9 8 } 10 - export default class SelectPrompt<T extends { value: any, label?: string }> extends Prompt { 9 + export default class SearchPrompt< 10 + T extends { value: any; label?: string; isSelected?: boolean }, 11 + > extends Prompt { 11 12 options: T[]; 12 13 valueWithCursor = ''; 13 14 14 - selectCursor = 0 15 - inputCursor = 1 16 - maxItems = 1 15 + selectCursor = 0; 16 + inputCursor = 0; 17 + maxItems = 1; 18 + selected: T[] = []; 17 19 private get _value() { 18 20 return this.options[this.selectCursor]; 19 21 } ··· 25 27 constructor(opts: SearchOptions<T>) { 26 28 super(opts, false); 27 29 this.options = opts.options; 30 + this.maxItems = opts.maxItems || 1; 31 + this.valueWithCursor = opts.initialValue || ''; 32 + if (this.valueWithCursor) { 33 + this.inputCursor = this.valueWithCursor.length; 34 + this.fuzzyFilter(opts); 35 + } 28 36 29 37 this.on('key', (v) => { 30 38 if (v.charCodeAt(0) === 127) { 31 - this.valueWithCursor = this.valueWithCursor.slice(0, this.inputCursor - 1) + this.valueWithCursor.slice(this.inputCursor); 39 + this.valueWithCursor = 40 + this.valueWithCursor.slice(0, this.inputCursor - 1) + 41 + this.valueWithCursor.slice(this.inputCursor); 32 42 this.inputCursor = this.inputCursor === 0 ? 0 : this.inputCursor - 1; 33 43 this.selectCursor = 0; 34 - } 35 - else if (v.charCodeAt(0) === 8) { 36 - // 往后删 37 - this.valueWithCursor = this.valueWithCursor.slice(0, this.inputCursor) + this.valueWithCursor.slice(this.inputCursor + 1); 44 + } else if (v.charCodeAt(0) === 8) { 45 + this.valueWithCursor = 46 + this.valueWithCursor.slice(0, this.inputCursor) + 47 + this.valueWithCursor.slice(this.inputCursor + 1); 38 48 this.selectCursor = 0; 39 - } 40 - else if ( v.charCodeAt(0) === 13 || v.charCodeAt(0) === 3) { 41 - return 42 - } 43 - else if (this.maxItems > 1 && v.charCodeAt(0) === 32 || v.charCodeAt(0) === 9) { 44 - // todo: 将当前 options 的 结果 反转,如果是选中,则不选中 45 - 46 - } 47 - else { 48 - this.valueWithCursor = this.valueWithCursor.slice(0, this.inputCursor) + v + this.valueWithCursor.slice(this.inputCursor); 49 + } else if (v.charCodeAt(0) === 13 || v.charCodeAt(0) === 3) { 50 + return; 51 + } else if ((this.maxItems > 1 && v.charCodeAt(0) === 32) || v.charCodeAt(0) === 9) { 52 + const value = this.options[this.selectCursor]; 53 + if (this.selected.includes(value)) { 54 + this.selected = this.selected.filter((v) => v !== value); 55 + } else { 56 + this.selected.push(value); 57 + } 58 + } else { 59 + this.valueWithCursor = 60 + this.valueWithCursor.slice(0, this.inputCursor) + 61 + v + 62 + this.valueWithCursor.slice(this.inputCursor); 49 63 this.inputCursor = this.inputCursor + 1; 50 64 this.selectCursor = 0; 51 65 } 52 - // 过滤 options 53 - // todo: 根据搜索命中的 index 再排序 54 - const fuzzyOptions = fuzzy.filter(this.valueWithCursor, opts.options, { extract: ({ label, value }) => label || value }).map(item => item.original) 55 - this.options = fuzzyOptions; 56 - this.changeValue() 66 + this.fuzzyFilter(opts); 57 67 }); 58 68 59 69 this.on('finalize', () => { 60 - this.valueWithCursor = this.options[this.selectCursor].value 70 + this.value = this.selected.map((item) => item.value); 61 71 }); 62 - 63 - this.changeValue(); 64 72 65 73 this.on('cursor', (key) => { 66 74 switch (key) { ··· 68 76 this.inputCursor = Math.max(0, this.inputCursor - 1); 69 77 break; 70 78 case 'up': 71 - this.selectCursor = this.selectCursor === 0 ? this.options.length - 1 : this.selectCursor - 1; 79 + this.selectCursor = 80 + this.selectCursor === 0 ? this.options.length - 1 : this.selectCursor - 1; 72 81 break; 73 82 case 'down': 74 - this.selectCursor = this.selectCursor === this.options.length - 1 ? 0 : this.selectCursor + 1; 75 - break 83 + this.selectCursor = 84 + this.selectCursor === this.options.length - 1 ? 0 : this.selectCursor + 1; 85 + break; 76 86 case 'right': 77 87 this.inputCursor = Math.min(this.valueWithCursor.length, this.inputCursor + 1); 78 88 break; 79 89 } 80 90 this.changeValue(); 81 91 }); 92 + } 93 + fuzzyFilter(opts: SearchOptions<T>) { 94 + const fuzzyOptions = fuzzy.filter(this.valueWithCursor, opts.options, { 95 + extract: ({ label, value }) => label || value, 96 + }); 97 + fuzzyOptions.sort((a, b) => { 98 + if (a.index === b.index) { 99 + return ( 100 + (a.original.label || a.original.value).indexOf(this.valueWithCursor) - 101 + (b.original.label || b.original.value).indexOf(this.valueWithCursor) 102 + ); 103 + } 104 + return a.index - b.index; 105 + }); 106 + this.options = fuzzyOptions.map((item) => item.original); 107 + this.changeValue(); 82 108 } 83 109 }
-19
packages/index.ts
··· 1 - import * as p from './prompts/src' 2 - 3 - p.group({ 4 - type: ({ results }) => 5 - p.search({ 6 - message: 'Search for a programming language', 7 - initialValue: 'ts', 8 - placeholder:'filter...', 9 - maxItems: 5, 10 - options: [ 11 - { value: 'ts', label: 'TypeScript' }, 12 - { value: 'js', label: 'JavaScript' }, 13 - { value: 'rust', label: 'Rust' }, 14 - { value: 'go', label: 'Go' }, 15 - { value: 'python', label: 'Python' }, 16 - { value: 'coffee', label: 'CoffeeScript', hint: 'oh no' }, 17 - ], 18 - }) 19 - })
+94 -53
packages/prompts/src/index.ts
··· 9 9 SelectPrompt, 10 10 State, 11 11 TextPrompt, 12 - SearchPrompt 12 + SearchPrompt, 13 13 } from '@clack/core'; 14 14 import isUnicodeSupported from 'is-unicode-supported'; 15 15 import color from 'picocolors'; ··· 156 156 case 'submit': 157 157 return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`; 158 158 case 'cancel': 159 - return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${masked ? '\n' + color.gray(S_BAR) : '' 160 - }`; 159 + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${ 160 + masked ? '\n' + color.gray(S_BAR) : '' 161 + }`; 161 162 default: 162 163 return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; 163 164 } ··· 190 191 color.dim(value) 191 192 )}\n${color.gray(S_BAR)}`; 192 193 default: { 193 - return `${title}${color.cyan(S_BAR)} ${this.value 194 - ? `${color.green(S_RADIO_ACTIVE)} ${active}` 195 - : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` 196 - } ${color.dim('/')} ${!this.value 194 + return `${title}${color.cyan(S_BAR)} ${ 195 + this.value 196 + ? `${color.green(S_RADIO_ACTIVE)} ${active}` 197 + : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` 198 + } ${color.dim('/')} ${ 199 + !this.value 197 200 ? `${color.green(S_RADIO_ACTIVE)} ${inactive}` 198 201 : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}` 199 - }\n${color.cyan(S_BAR_END)}\n`; 202 + }\n${color.cyan(S_BAR_END)}\n`; 200 203 } 201 204 } 202 205 }, ··· 216 219 maxItems?: number; 217 220 } 218 221 219 - export interface SearchOptions<Value> { 222 + export interface SearchOptions<Value, MaxItems> { 220 223 message?: string; 221 224 options: Option<Value>[]; 222 225 initialValue?: Value; 223 226 placeholder?: string; 224 - value?: string; 225 - maxItems?: number; 227 + value?: MaxItems extends 1 ? string : string[]; 228 + maxItems?: MaxItems; 226 229 } 227 230 228 231 export const select = <Value>(opts: SelectOptions<Value>) => { ··· 232 235 case 'selected': 233 236 return `${color.dim(label)}`; 234 237 case 'active': 235 - return `${color.green(S_RADIO_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 236 - }`; 238 + return `${color.green(S_RADIO_ACTIVE)} ${label} ${ 239 + option.hint ? color.dim(`(${option.hint})`) : '' 240 + }`; 237 241 case 'cancelled': 238 242 return `${color.strikethrough(color.dim(label))}`; 239 243 default: ··· 279 283 } else if (state === 'cancelled') { 280 284 return `${color.strikethrough(color.dim(label))}`; 281 285 } else if (state === 'active') { 282 - return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 283 - }`; 286 + return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${ 287 + option.hint ? color.dim(`(${option.hint})`) : '' 288 + }`; 284 289 } 285 - return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 286 - }`; 290 + return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${ 291 + option.hint ? color.dim(`(${option.hint})`) : '' 292 + }`; 287 293 }; 288 294 289 295 return new SelectKeyPrompt({ ··· 312 318 }).prompt() as Promise<Value | symbol>; 313 319 }; 314 320 315 - export const search = <Value>(opts: SearchOptions<Value>) => { 321 + export const search = <Value, MaxItems extends number>(opts: SearchOptions<Value, MaxItems>) => { 316 322 const opt = ( 317 323 option: Option<Value>, 318 - state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive' 324 + state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'submitted' | 'cancelled' 319 325 ) => { 320 326 const label = option.label ?? String(option.value); 321 - if (state === 'selected') { 322 - return `${color.dim(label)}`; 327 + if (state === 'active') { 328 + return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ 329 + option.hint ? color.dim(`(${option.hint})`) : '' 330 + }`; 331 + } else if (state === 'selected') { 332 + return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; 323 333 } else if (state === 'cancelled') { 324 334 return `${color.strikethrough(color.dim(label))}`; 325 - } else if (state === 'active') { 326 - return `${color.green(S_RADIO_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 335 + } else if (state === 'active-selected') { 336 + return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${ 337 + option.hint ? color.dim(`(${option.hint})`) : '' 327 338 }`; 339 + } else if (state === 'submitted') { 340 + return `${color.dim(label)}`; 328 341 } 329 - return `${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 330 - }`; 342 + return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; 331 343 }; 332 344 333 345 return new SearchPrompt({ ··· 335 347 initialValue: opts.initialValue, 336 348 maxItems: opts.maxItems, 337 349 render() { 338 - const title = opts.message ? `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n` : ''; 350 + const title = opts.message 351 + ? `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n` 352 + : ''; 339 353 const placeholder = opts.placeholder 340 354 ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1)) 341 355 : color.inverse(color.hidden('_')); 342 - let value = '' 356 + let value = ''; 343 357 if (this.valueWithCursor) { 344 - const pos = Math.min(Math.max(this.inputCursor - 1, 0)) 345 - value = this.valueWithCursor.slice(0, pos) + color.inverse(this.valueWithCursor[pos] || '') + this.valueWithCursor.slice(pos + 1) || '' 358 + const pos = Math.min(Math.max(this.inputCursor - 1, 0)); 359 + value = 360 + this.valueWithCursor.slice(0, pos) + 361 + color.inverse(this.valueWithCursor[pos] || '') + 362 + this.valueWithCursor.slice(pos + 1) || ''; 346 363 } else { 347 - value = placeholder 364 + value = placeholder; 348 365 } 349 - // 要根据当前光标位置插入输入的字符 350 366 switch (this.state) { 351 367 case 'submit': 352 368 return `${title}${color.gray(S_BAR)} ${opt( 353 - this.options.find((opt) => opt.value === this.value)!, 369 + { 370 + label: 371 + this.selected.map((item) => item.label).join(', ') || 372 + this.options[this.selectCursor].label, 373 + } as Option<Value>, 354 374 'selected' 355 375 )}`; 356 376 case 'cancel': ··· 358 378 S_BAR 359 379 )}`; 360 380 default: { 361 - return `${title}${color.cyan(S_BAR)}\n${color.cyan(S_INFO)} ${value}\n${color.cyan(S_BAR)} ${this.options 362 - .map((option, i) => opt(option, i === this.selectCursor ? 'active' : 'inactive')) 381 + let getStatus = (option: Option<Value>, i: number) => { 382 + if (i === this.selectCursor) { 383 + return this.selected.some((item) => item.value === option.value) 384 + ? 'active-selected' 385 + : 'active'; 386 + } 387 + return this.selected.some((item) => item.value === option.value) 388 + ? 'selected' 389 + : i === this.selectCursor 390 + ? 'active' 391 + : 'inactive'; 392 + }; 393 + return `${title}${color.cyan(S_BAR)}\n${color.cyan(S_INFO)} ${value}\n${color.cyan( 394 + S_BAR 395 + )} ${this.options 396 + .map((option, i) => opt(option, getStatus(option, i))) 363 397 .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; 364 398 } 365 399 } ··· 382 416 ) => { 383 417 const label = option.label ?? String(option.value); 384 418 if (state === 'active') { 385 - return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 386 - }`; 419 + return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ 420 + option.hint ? color.dim(`(${option.hint})`) : '' 421 + }`; 387 422 } else if (state === 'selected') { 388 423 return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; 389 424 } else if (state === 'cancelled') { 390 425 return `${color.strikethrough(color.dim(label))}`; 391 426 } else if (state === 'active-selected') { 392 - return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 393 - }`; 427 + return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${ 428 + option.hint ? color.dim(`(${option.hint})`) : '' 429 + }`; 394 430 } else if (state === 'submitted') { 395 431 return `${color.dim(label)}`; 396 432 } ··· 428 464 429 465 switch (this.state) { 430 466 case 'submit': { 431 - return `${title}${color.gray(S_BAR)} ${this.options 432 - .filter(({ value }) => this.value.includes(value)) 433 - .map((option) => opt(option, 'submitted')) 434 - .join(color.dim(', ')) || color.dim('none') 435 - }`; 467 + return `${title}${color.gray(S_BAR)} ${ 468 + this.options 469 + .filter(({ value }) => this.value.includes(value)) 470 + .map((option) => opt(option, 'submitted')) 471 + .join(color.dim(', ')) || color.dim('none') 472 + }`; 436 473 } 437 474 case 'cancel': { 438 475 const label = this.options 439 476 .filter(({ value }) => this.value.includes(value)) 440 477 .map((option) => opt(option, 'cancelled')) 441 478 .join(color.dim(', ')); 442 - return `${title}${color.gray(S_BAR)} ${label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' 443 - }`; 479 + return `${title}${color.gray(S_BAR)} ${ 480 + label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' 481 + }`; 444 482 } 445 483 case 'error': { 446 484 const footer = this.error ··· 505 543 const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ''; 506 544 507 545 if (state === 'active') { 508 - return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 509 - }`; 546 + return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ 547 + option.hint ? color.dim(`(${option.hint})`) : '' 548 + }`; 510 549 } else if (state === 'group-active') { 511 550 return `${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`; 512 551 } else if (state === 'group-active-selected') { ··· 516 555 } else if (state === 'cancelled') { 517 556 return `${color.strikethrough(color.dim(label))}`; 518 557 } else if (state === 'active-selected') { 519 - return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' 520 - }`; 558 + return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${ 559 + option.hint ? color.dim(`(${option.hint})`) : '' 560 + }`; 521 561 } else if (state === 'submitted') { 522 562 return `${color.dim(label)}`; 523 563 } ··· 554 594 .filter(({ value }) => this.value.includes(value)) 555 595 .map((option) => opt(option, 'cancelled')) 556 596 .join(color.dim(', ')); 557 - return `${title}${color.gray(S_BAR)} ${label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' 558 - }`; 597 + return `${title}${color.gray(S_BAR)} ${ 598 + label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' 599 + }`; 559 600 } 560 601 case 'error': { 561 602 const footer = this.error ··· 751 792 code === 0 752 793 ? color.green(S_STEP_SUBMIT) 753 794 : code === 1 754 - ? color.red(S_STEP_CANCEL) 755 - : color.red(S_STEP_ERROR); 795 + ? color.red(S_STEP_CANCEL) 796 + : color.red(S_STEP_ERROR); 756 797 process.stdout.write(cursor.move(-999, 0)); 757 798 process.stdout.write(erase.down(1)); 758 799 process.stdout.write(`${step} ${_message}\n`);