index.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { createPrompt, useState, useKeypress, usePrefix, usePagination, useRef, useEffect, useMemo, isEnterKey, Separator, makeTheme, } from '@inquirer/core';
  2. import colors from 'yoctocolors-cjs';
  3. import figures from '@inquirer/figures';
  4. const searchTheme = {
  5. icon: { cursor: figures.pointer },
  6. style: {
  7. disabled: (text) => colors.dim(`- ${text}`),
  8. searchTerm: (text) => colors.cyan(text),
  9. description: (text) => colors.cyan(text),
  10. },
  11. helpMode: 'auto',
  12. };
  13. function isSelectable(item) {
  14. return !Separator.isSeparator(item) && !item.disabled;
  15. }
  16. function normalizeChoices(choices) {
  17. return choices.map((choice) => {
  18. if (Separator.isSeparator(choice))
  19. return choice;
  20. if (typeof choice === 'string') {
  21. return {
  22. value: choice,
  23. name: choice,
  24. short: choice,
  25. disabled: false,
  26. };
  27. }
  28. const name = choice.name ?? String(choice.value);
  29. const normalizedChoice = {
  30. value: choice.value,
  31. name,
  32. short: choice.short ?? name,
  33. disabled: choice.disabled ?? false,
  34. };
  35. if (choice.description) {
  36. normalizedChoice.description = choice.description;
  37. }
  38. return normalizedChoice;
  39. });
  40. }
  41. export default createPrompt((config, done) => {
  42. const { pageSize = 7, validate = () => true } = config;
  43. const theme = makeTheme(searchTheme, config.theme);
  44. const firstRender = useRef(true);
  45. const [status, setStatus] = useState('loading');
  46. const [searchTerm, setSearchTerm] = useState('');
  47. const [searchResults, setSearchResults] = useState([]);
  48. const [searchError, setSearchError] = useState();
  49. const prefix = usePrefix({ status, theme });
  50. const bounds = useMemo(() => {
  51. const first = searchResults.findIndex(isSelectable);
  52. const last = searchResults.findLastIndex(isSelectable);
  53. return { first, last };
  54. }, [searchResults]);
  55. const [active = bounds.first, setActive] = useState();
  56. useEffect(() => {
  57. const controller = new AbortController();
  58. setStatus('loading');
  59. setSearchError(undefined);
  60. const fetchResults = async () => {
  61. try {
  62. const results = await config.source(searchTerm || undefined, {
  63. signal: controller.signal,
  64. });
  65. if (!controller.signal.aborted) {
  66. // Reset the pointer
  67. setActive(undefined);
  68. setSearchError(undefined);
  69. setSearchResults(normalizeChoices(results));
  70. setStatus('idle');
  71. }
  72. }
  73. catch (error) {
  74. if (!controller.signal.aborted && error instanceof Error) {
  75. setSearchError(error.message);
  76. }
  77. }
  78. };
  79. void fetchResults();
  80. return () => {
  81. controller.abort();
  82. };
  83. }, [searchTerm]);
  84. // Safe to assume the cursor position never points to a Separator.
  85. const selectedChoice = searchResults[active];
  86. useKeypress(async (key, rl) => {
  87. if (isEnterKey(key)) {
  88. if (selectedChoice) {
  89. setStatus('loading');
  90. const isValid = await validate(selectedChoice.value);
  91. setStatus('idle');
  92. if (isValid === true) {
  93. setStatus('done');
  94. done(selectedChoice.value);
  95. }
  96. else if (selectedChoice.name === searchTerm) {
  97. setSearchError(isValid || 'You must provide a valid value');
  98. }
  99. else {
  100. // Reset line with new search term
  101. rl.write(selectedChoice.name);
  102. setSearchTerm(selectedChoice.name);
  103. }
  104. }
  105. else {
  106. // Reset the readline line value to the previous value. On line event, the value
  107. // get cleared, forcing the user to re-enter the value instead of fixing it.
  108. rl.write(searchTerm);
  109. }
  110. }
  111. else if (key.name === 'tab' && selectedChoice) {
  112. rl.clearLine(0); // Remove the tab character.
  113. rl.write(selectedChoice.name);
  114. setSearchTerm(selectedChoice.name);
  115. }
  116. else if (status !== 'loading' && (key.name === 'up' || key.name === 'down')) {
  117. rl.clearLine(0);
  118. if ((key.name === 'up' && active !== bounds.first) ||
  119. (key.name === 'down' && active !== bounds.last)) {
  120. const offset = key.name === 'up' ? -1 : 1;
  121. let next = active;
  122. do {
  123. next = (next + offset + searchResults.length) % searchResults.length;
  124. } while (!isSelectable(searchResults[next]));
  125. setActive(next);
  126. }
  127. }
  128. else {
  129. setSearchTerm(rl.line);
  130. }
  131. });
  132. const message = theme.style.message(config.message, status);
  133. if (active > 0) {
  134. firstRender.current = false;
  135. }
  136. let helpTip = '';
  137. if (searchResults.length > 1 &&
  138. (theme.helpMode === 'always' || (theme.helpMode === 'auto' && firstRender.current))) {
  139. helpTip =
  140. searchResults.length > pageSize
  141. ? `\n${theme.style.help('(Use arrow keys to reveal more choices)')}`
  142. : `\n${theme.style.help('(Use arrow keys)')}`;
  143. }
  144. // TODO: What to do if no results are found? Should we display a message?
  145. const page = usePagination({
  146. items: searchResults,
  147. active,
  148. renderItem({ item, isActive }) {
  149. if (Separator.isSeparator(item)) {
  150. return ` ${item.separator}`;
  151. }
  152. if (item.disabled) {
  153. const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)';
  154. return theme.style.disabled(`${item.name} ${disabledLabel}`);
  155. }
  156. const color = isActive ? theme.style.highlight : (x) => x;
  157. const cursor = isActive ? theme.icon.cursor : ` `;
  158. return color(`${cursor} ${item.name}`);
  159. },
  160. pageSize,
  161. loop: false,
  162. });
  163. let error;
  164. if (searchError) {
  165. error = theme.style.error(searchError);
  166. }
  167. else if (searchResults.length === 0 && searchTerm !== '' && status === 'idle') {
  168. error = theme.style.error('No results found');
  169. }
  170. let searchStr;
  171. if (status === 'done' && selectedChoice) {
  172. const answer = selectedChoice.short;
  173. return `${prefix} ${message} ${theme.style.answer(answer)}`;
  174. }
  175. else {
  176. searchStr = theme.style.searchTerm(searchTerm);
  177. }
  178. const choiceDescription = selectedChoice?.description
  179. ? `\n${theme.style.description(selectedChoice.description)}`
  180. : ``;
  181. return [
  182. [prefix, message, searchStr].filter(Boolean).join(' '),
  183. `${error ?? page}${helpTip}${choiceDescription}`,
  184. ];
  185. });
  186. export { Separator } from '@inquirer/core';