From 855f7dcf167e3572d0660993f4e5e28d141d077e Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sun, 1 Sep 2024 17:50:44 -0400 Subject: [PATCH] Refactor(@inquirer/core) Cleanup the exit logic & promise constructor --- packages/core/src/lib/create-prompt.mts | 138 ++++++++++++------------ packages/type/src/inquirer.mts | 11 ++ 2 files changed, 79 insertions(+), 70 deletions(-) diff --git a/packages/core/src/lib/create-prompt.mts b/packages/core/src/lib/create-prompt.mts index 7b757dffa..121fade0d 100644 --- a/packages/core/src/lib/create-prompt.mts +++ b/packages/core/src/lib/create-prompt.mts @@ -1,10 +1,10 @@ import * as readline from 'node:readline'; import { AsyncResource } from 'node:async_hooks'; -import { CancelablePromise, type Prompt, type Prettify } from '@inquirer/type'; +import { type Prompt, type Prettify } from '@inquirer/type'; import MuteStream from 'mute-stream'; import { onExit as onSignalExit } from 'signal-exit'; import ScreenManager from './screen-manager.mjs'; -import type { InquirerReadline } from '@inquirer/type'; +import { CancelablePromise, type InquirerReadline } from '@inquirer/type'; import { withHooks, effectScheduler } from './hook-engine.mjs'; import { CancelPromptError, ExitPromptError } from './errors.mjs'; @@ -14,13 +14,13 @@ type ViewFunction = ( ) => string | [string, string | undefined]; export function createPrompt(view: ViewFunction) { - const prompt: Prompt = (config, context) => { + const prompt: Prompt = (config, context = {}) => { // Default `input` to stdin - const input = context?.input ?? process.stdin; + const { input = process.stdin } = context; // Add mute capabilities to the output const output = new MuteStream(); - output.pipe(context?.output ?? process.stdout); + output.pipe(context.output ?? process.stdout); const rl = readline.createInterface({ terminal: true, @@ -29,84 +29,82 @@ export function createPrompt(view: ViewFunction) { }) as InquirerReadline; const screen = new ScreenManager(rl); - let cancel: () => void = () => {}; - const answer = new CancelablePromise((resolve, reject) => { - withHooks(rl, (cycle) => { - function checkCursorPos() { - screen.checkCursorPos(); - } + const cleanups = new Set<() => void>(); + const { promise, resolve, reject } = CancelablePromise.withResolver(); - const removeExitListener = onSignalExit((code, signal) => { - onExit(); - reject( - new ExitPromptError(`User force closed the prompt with ${code} ${signal}`), - ); - }); + function onExit() { + cleanups.forEach((cleanup) => cleanup()); - const hooksCleanup = AsyncResource.bind(() => { - try { - effectScheduler.clearAll(); - } catch (error) { - reject(error); - } - }); - - function onExit() { - hooksCleanup(); + screen.done({ clearContent: Boolean(context?.clearPromptOnDone) }); + output.end(); + } - screen.done({ clearContent: Boolean(context?.clearPromptOnDone) }); + function fail(error: unknown) { + onExit(); + reject(error); + } - removeExitListener(); - rl.input.removeListener('keypress', checkCursorPos); - rl.removeListener('close', hooksCleanup); - output.end(); + withHooks(rl, (cycle) => { + cleanups.add( + onSignalExit((code, signal) => { + fail( + new ExitPromptError(`User force closed the prompt with ${code} ${signal}`), + ); + }), + ); + + const hooksCleanup = AsyncResource.bind(() => { + try { + effectScheduler.clearAll(); + } catch (error) { + reject(error); } - - cancel = () => { + }); + cleanups.add(hooksCleanup); + + // Re-renders only happen when the state change; but the readline cursor could change position + // and that also requires a re-render (and a manual one because we mute the streams). + // We set the listener after the initial workLoop to avoid a double render if render triggered + // by a state change sets the cursor to the right position. + const checkCursorPos = () => screen.checkCursorPos(); + rl.input.on('keypress', checkCursorPos); + cleanups.add(() => rl.input.removeListener('keypress', checkCursorPos)); + + // The close event triggers immediately when the user press ctrl+c. SignalExit on the other hand + // triggers after the process is done (which happens after timeouts are done triggering.) + // We triggers the hooks cleanup phase on rl `close` so active timeouts can be cleared. + rl.on('close', hooksCleanup); + cleanups.add(() => rl.removeListener('close', hooksCleanup)); + + function done(value: Value) { + // Delay execution to let time to the hookCleanup functions to registers. + setImmediate(() => { onExit(); - reject(new CancelPromptError()); - }; - - function done(value: Value) { - // Delay execution to let time to the hookCleanup functions to registers. - setImmediate(() => { - onExit(); - - // Finally we resolve our promise - resolve(value); - }); - } - cycle(() => { - try { - const nextView = view(config, done); - - const [content, bottomContent] = - typeof nextView === 'string' ? [nextView] : nextView; - screen.render(content, bottomContent); - - effectScheduler.run(); - } catch (error) { - onExit(); - reject(error); - } + // Finally we resolve our promise + resolve(value); }); + } - // Re-renders only happen when the state change; but the readline cursor could change position - // and that also requires a re-render (and a manual one because we mute the streams). - // We set the listener after the initial workLoop to avoid a double render if render triggered - // by a state change sets the cursor to the right position. - rl.input.on('keypress', checkCursorPos); + cycle(() => { + try { + const nextView = view(config, done); - // The close event triggers immediately when the user press ctrl+c. SignalExit on the other hand - // triggers after the process is done (which happens after timeouts are done triggering.) - // We triggers the hooks cleanup phase on rl `close` so active timeouts can be cleared. - rl.on('close', hooksCleanup); + const [content, bottomContent] = + typeof nextView === 'string' ? [nextView] : nextView; + screen.render(content, bottomContent); + + effectScheduler.run(); + } catch (error: unknown) { + fail(error); + } }); }); - answer.cancel = cancel; - return answer; + promise.cancel = () => { + fail(new CancelPromptError()); + }; + return promise; }; return prompt; diff --git a/packages/type/src/inquirer.mts b/packages/type/src/inquirer.mts index 8e8bb6084..3d42369e3 100644 --- a/packages/type/src/inquirer.mts +++ b/packages/type/src/inquirer.mts @@ -3,6 +3,17 @@ import MuteStream from 'mute-stream'; export class CancelablePromise extends Promise { public cancel: () => void = () => {}; + + static withResolver() { + let resolve: (value: T) => void; + let reject: (error: unknown) => void; + const promise = new CancelablePromise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve: resolve!, reject: reject! }; + } } export type InquirerReadline = readline.ReadLine & {