Skip to content

Commit

Permalink
Refactor(@inquirer/core) Cleanup the exit logic & promise constructor
Browse files Browse the repository at this point in the history
  • Loading branch information
SBoudrias committed Sep 1, 2024
1 parent e17b0c7 commit 855f7dc
Show file tree
Hide file tree
Showing 2 changed files with 79 additions and 70 deletions.
138 changes: 68 additions & 70 deletions packages/core/src/lib/create-prompt.mts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -14,13 +14,13 @@ type ViewFunction<Value, Config> = (
) => string | [string, string | undefined];

export function createPrompt<Value, Config>(view: ViewFunction<Value, Config>) {
const prompt: Prompt<Value, Config> = (config, context) => {
const prompt: Prompt<Value, Config> = (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,
Expand All @@ -29,84 +29,82 @@ export function createPrompt<Value, Config>(view: ViewFunction<Value, Config>) {
}) as InquirerReadline;
const screen = new ScreenManager(rl);

let cancel: () => void = () => {};
const answer = new CancelablePromise<Value>((resolve, reject) => {
withHooks(rl, (cycle) => {
function checkCursorPos() {
screen.checkCursorPos();
}
const cleanups = new Set<() => void>();
const { promise, resolve, reject } = CancelablePromise.withResolver<Value>();

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;
Expand Down
11 changes: 11 additions & 0 deletions packages/type/src/inquirer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ import MuteStream from 'mute-stream';

export class CancelablePromise<T> extends Promise<T> {
public cancel: () => void = () => {};

static withResolver<T>() {
let resolve: (value: T) => void;
let reject: (error: unknown) => void;
const promise = new CancelablePromise<T>((res, rej) => {
resolve = res;
reject = rej;
});

return { promise, resolve: resolve!, reject: reject! };
}
}

export type InquirerReadline = readline.ReadLine & {
Expand Down

0 comments on commit 855f7dc

Please sign in to comment.