diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7299d075..66752310 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,8 @@ export type { SelectOptions } from './prompts/select.js'; export { default as SelectPrompt } from './prompts/select.js'; export type { SelectKeyOptions } from './prompts/select-key.js'; export { default as SelectKeyPrompt } from './prompts/select-key.js'; +export type { SpinnerOptions } from './prompts/spinner.js'; +export { default as SpinnerPrompt } from './prompts/spinner.js'; export type { TextOptions } from './prompts/text.js'; export { default as TextPrompt } from './prompts/text.js'; export type { ClackState as State } from './types.js'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index b30deb02..7c54c4b4 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -270,7 +270,7 @@ export default class Prompt { this.output.write(cursor.move(-999, lines * -1)); } - private render() { + protected render() { const frame = wrapAnsi(this._render(this) ?? '', process.stdout.columns, { hard: true, trim: false, diff --git a/packages/core/src/prompts/spinner.ts b/packages/core/src/prompts/spinner.ts new file mode 100644 index 00000000..88ac8f7b --- /dev/null +++ b/packages/core/src/prompts/spinner.ts @@ -0,0 +1,179 @@ +import { settings } from '../utils/index.js'; +import Prompt, { type PromptOptions } from './prompt.js'; + +const removeTrailingDots = (msg: string): string => { + return msg.replace(/\.+$/, ''); +}; + +export interface SpinnerOptions extends PromptOptions { + indicator?: 'dots' | 'timer'; + onCancel?: () => void; + cancelMessage?: string; + errorMessage?: string; + frames: string[]; + delay: number; + styleFrame?: (frame: string) => string; +} + +export default class SpinnerPrompt extends Prompt { + #isCancelled = false; + #isActive = false; + #startTime: number = 0; + #frameIndex: number = 0; + #indicatorTimer: number = 0; + #intervalId: ReturnType | undefined; + #delay: number; + #frames: string[]; + #cancelMessage: string; + #errorMessage: string; + #onCancel?: () => void; + #message: string = ''; + #silentExit: boolean = false; + #exitCode: number = 0; + + constructor(opts: SpinnerOptions) { + super(opts); + this.#delay = opts.delay; + this.#frames = opts.frames; + this.#cancelMessage = opts.cancelMessage ?? settings.messages.cancel; + this.#errorMessage = opts.errorMessage ?? settings.messages.error; + this.#onCancel = opts.onCancel; + + this.on('cancel', () => this.#onExit(1)); + } + + start(msg?: string): void { + if (this.#isActive) { + this.#reset(); + } + this.#isActive = true; + this.#message = removeTrailingDots(msg ?? ''); + this.#startTime = performance.now(); + this.#frameIndex = 0; + this.#indicatorTimer = 0; + + this.#intervalId = setInterval(() => this.#onInterval(), this.#delay); + + this.#addGlobalListeners(); + } + + stop(msg?: string, exitCode?: number, silent?: boolean): void { + if (!this.#isActive) { + return; + } + + this.#reset(); + this.#silentExit = silent === true; + this.#exitCode = exitCode ?? 0; + + if (msg !== undefined) { + this.#message = msg; + } + + this.state = 'cancel'; + this.render(); + this.close(); + } + + get isCancelled(): boolean { + return this.#isCancelled; + } + + get message(): string { + return this.#message; + } + + set message(msg: string) { + this.#message = removeTrailingDots(msg); + } + + get exitCode(): number | undefined { + return this.#exitCode; + } + + get frameIndex(): number { + return this.#frameIndex; + } + + get indicatorTimer(): number { + return this.#indicatorTimer; + } + + get isActive(): boolean { + return this.#isActive; + } + + get silentExit(): boolean { + return this.#silentExit; + } + + getFormattedTimer(): string { + const duration = (performance.now() - this.#startTime) / 1000; + const min = Math.floor(duration / 60); + const secs = Math.floor(duration % 60); + return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; + } + + #reset(): void { + this.#isActive = false; + this.#exitCode = 0; + + if (this.#intervalId) { + clearInterval(this.#intervalId); + this.#intervalId = undefined; + } + + this.#removeGlobalListeners(); + } + + #onInterval(): void { + this.render(); + + this.#frameIndex = this.#frameIndex + 1 < this.#frames.length ? this.#frameIndex + 1 : 0; + // indicator increase by 1 every 8 frames + this.#indicatorTimer = this.#indicatorTimer < 4 ? this.#indicatorTimer + 0.125 : 0; + } + + #onProcessError: () => void = () => { + this.#onExit(2); + }; + + #onProcessSignal: () => void = () => { + this.#onExit(1); + }; + + #onExit: (exitCode: number) => void = (exitCode) => { + this.#exitCode = exitCode; + if (exitCode > 1) { + this.#message = this.#errorMessage; + } else { + this.#message = this.#cancelMessage; + } + this.#isCancelled = exitCode === 1; + if (this.#isActive) { + this.stop(this.#message, exitCode); + if (this.#isCancelled && this.#onCancel) { + this.#onCancel(); + } + } + }; + + #addGlobalListeners(): void { + // Reference: https://nodejs.org/api/process.html#event-uncaughtexception + process.on('uncaughtExceptionMonitor', this.#onProcessError); + // Reference: https://nodejs.org/api/process.html#event-unhandledrejection + process.on('unhandledRejection', this.#onProcessError); + // Reference Signal Events: https://nodejs.org/api/process.html#signal-events + process.on('SIGINT', this.#onProcessSignal); + process.on('SIGTERM', this.#onProcessSignal); + process.on('exit', this.#onExit); + } + + #removeGlobalListeners(): void { + process.removeListener('uncaughtExceptionMonitor', this.#onProcessError); + process.removeListener('unhandledRejection', this.#onProcessError); + process.removeListener('SIGINT', this.#onProcessSignal); + process.removeListener('SIGTERM', this.#onProcessSignal); + process.removeListener('exit', this.#onExit); + } +} diff --git a/packages/core/test/prompts/spinner.test.ts b/packages/core/test/prompts/spinner.test.ts new file mode 100644 index 00000000..7ca19d0a --- /dev/null +++ b/packages/core/test/prompts/spinner.test.ts @@ -0,0 +1,151 @@ +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as SpinnerPrompt } from '../../src/prompts/spinner.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe('SpinnerPrompt', () => { + let input: MockReadable; + let output: MockWritable; + let instance: SpinnerPrompt; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + instance.stop(); + }); + + test('renders render() result', () => { + instance = new SpinnerPrompt({ + input, + output, + frames: ['J', 'A', 'M', 'E', 'S'], + delay: 5, + render: () => 'foo', + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + describe('start', () => { + test('starts the spinner and updates frames', () => { + instance = new SpinnerPrompt({ + input, + output, + frames: ['J', 'A', 'M', 'E', 'S'], + delay: 5, + render: () => 'foo', + }); + instance.start('Loading'); + expect(instance.message).to.equal('Loading'); + expect(instance.frameIndex).to.equal(0); + expect(instance.indicatorTimer).to.equal(0); + vi.advanceTimersByTime(5); + expect(instance.frameIndex).to.equal(1); + expect(instance.indicatorTimer).to.equal(0.125); + vi.advanceTimersByTime(5); + expect(instance.frameIndex).to.equal(2); + expect(instance.indicatorTimer).to.equal(0.25); + vi.advanceTimersByTime(5); + expect(instance.frameIndex).to.equal(3); + expect(instance.indicatorTimer).to.equal(0.375); + }); + + test('starting again resets the spinner', () => { + instance = new SpinnerPrompt({ + input, + output, + frames: ['J', 'A', 'M', 'E', 'S'], + delay: 5, + render: () => 'foo', + }); + instance.start('Loading'); + vi.advanceTimersByTime(10); + expect(instance.frameIndex).to.equal(2); + expect(instance.indicatorTimer).to.equal(0.25); + expect(instance.message).to.equal('Loading'); + instance.start('Loading again'); + expect(instance.message).to.equal('Loading again'); + expect(instance.frameIndex).to.equal(0); + expect(instance.indicatorTimer).to.equal(0); + }); + }); + + describe('stop', () => { + test('stops the spinner and sets message', () => { + instance = new SpinnerPrompt({ + input, + output, + frames: ['J', 'A', 'M', 'E', 'S'], + delay: 5, + render: () => 'foo', + }); + instance.start('Loading'); + vi.advanceTimersByTime(10); + instance.stop('Done'); + expect(instance.message).to.equal('Canceled'); + expect(instance.isActive).to.equal(false); + expect(instance.isCancelled).to.equal(true); + expect(instance.silentExit).to.equal(false); + expect(instance.exitCode).to.equal(1); + expect(instance.state).to.equal('cancel'); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n']); + }); + + test('does nothing if spinner is not active', () => { + instance = new SpinnerPrompt({ + input, + output, + frames: ['J', 'A', 'M', 'E', 'S'], + delay: 5, + render: () => 'foo', + }); + instance.stop('Done'); + expect(instance.message).to.equal(''); + expect(instance.isActive).to.equal(false); + expect(instance.silentExit).to.equal(false); + expect(instance.exitCode).to.equal(undefined); + expect(instance.state).to.equal('initial'); + expect(output.buffer).to.deep.equal([]); + }); + }); + + test('message strips trailing dots', () => { + instance = new SpinnerPrompt({ + input, + output, + frames: ['J', 'A', 'M', 'E', 'S'], + delay: 5, + render: () => 'foo', + }); + instance.start('Loading...'); + expect(instance.message).to.equal('Loading'); + + instance.message = 'Still loading....'; + expect(instance.message).to.equal('Still loading'); + }); + + describe('getFormattedTimer', () => { + test('formats timer correctly', () => { + instance = new SpinnerPrompt({ + input, + output, + frames: ['J', 'A', 'M', 'E', 'S'], + delay: 5, + render: () => 'foo', + }); + instance.start(); + expect(instance.getFormattedTimer()).to.equal('[0s]'); + vi.advanceTimersByTime(1500); + expect(instance.getFormattedTimer()).to.equal('[1s]'); + vi.advanceTimersByTime(600_000); + expect(instance.getFormattedTimer()).to.equal('[10m 1s]'); + }); + }); +}); diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 9b427e3a..5f137b00 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -1,7 +1,5 @@ import { styleText } from 'node:util'; -import { block, getColumns, settings } from '@clack/core'; -import { wrapAnsi } from 'fast-wrap-ansi'; -import { cursor, erase } from 'sisteransi'; +import { SpinnerPrompt, settings } from '@clack/core'; import { type CommonOptions, isCI as isCIFn, @@ -37,183 +35,74 @@ const defaultStyleFn: SpinnerOptions['styleFrame'] = (frame) => styleText('magen export const spinner = ({ indicator = 'dots', onCancel, - output = process.stdout, cancelMessage, errorMessage, frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'], delay = unicode ? 80 : 120, - signal, ...opts }: SpinnerOptions = {}): SpinnerResult => { const isCI = isCIFn(); - - let unblock: () => void; - let loop: NodeJS.Timeout; - let isSpinnerActive = false; - let isCancelled = false; - let _message = ''; - let _prevMessage: string | undefined; - let _origin: number = performance.now(); - const columns = getColumns(output); const styleFn = opts?.styleFrame ?? defaultStyleFn; - - const handleExit = (code: number) => { - const msg = - code > 1 - ? (errorMessage ?? settings.messages.error) - : (cancelMessage ?? settings.messages.cancel); - isCancelled = code === 1; - if (isSpinnerActive) { - _stop(msg, code); - if (isCancelled && typeof onCancel === 'function') { - onCancel(); - } - } - }; - - const errorEventHandler = () => handleExit(2); - const signalEventHandler = () => handleExit(1); - - const registerHooks = () => { - // Reference: https://nodejs.org/api/process.html#event-uncaughtexception - process.on('uncaughtExceptionMonitor', errorEventHandler); - // Reference: https://nodejs.org/api/process.html#event-unhandledrejection - process.on('unhandledRejection', errorEventHandler); - // Reference Signal Events: https://nodejs.org/api/process.html#signal-events - process.on('SIGINT', signalEventHandler); - process.on('SIGTERM', signalEventHandler); - process.on('exit', handleExit); - - if (signal) { - signal.addEventListener('abort', signalEventHandler); - } - }; - - const clearHooks = () => { - process.removeListener('uncaughtExceptionMonitor', errorEventHandler); - process.removeListener('unhandledRejection', errorEventHandler); - process.removeListener('SIGINT', signalEventHandler); - process.removeListener('SIGTERM', signalEventHandler); - process.removeListener('exit', handleExit); - - if (signal) { - signal.removeEventListener('abort', signalEventHandler); - } - }; - - const clearPrevMessage = () => { - if (_prevMessage === undefined) return; - if (isCI) output.write('\n'); - const wrapped = wrapAnsi(_prevMessage, columns, { - hard: true, - trim: false, - }); - const prevLines = wrapped.split('\n'); - if (prevLines.length > 1) { - output.write(cursor.up(prevLines.length - 1)); - } - output.write(cursor.to(0)); - output.write(erase.down()); - }; - - const removeTrailingDots = (msg: string): string => { - return msg.replace(/\.+$/, ''); - }; - - const formatTimer = (origin: number): string => { - const duration = (performance.now() - origin) / 1000; - const min = Math.floor(duration / 60); - const secs = Math.floor(duration % 60); - return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; - }; - - const hasGuide = opts.withGuide ?? settings.withGuide; - - const start = (msg = ''): void => { - isSpinnerActive = true; - unblock = block({ output }); - _message = removeTrailingDots(msg); - _origin = performance.now(); - if (hasGuide) { - output.write(`${styleText('gray', S_BAR)}\n`); - } - let frameIndex = 0; - let indicatorTimer = 0; - registerHooks(); - loop = setInterval(() => { - if (isCI && _message === _prevMessage) { - return; + const prompt = new SpinnerPrompt({ + indicator, + onCancel, + cancelMessage, + errorMessage, + frames, + delay, + output: opts.output, + signal: opts.signal, + input: opts.input, + render() { + const hasGuide = opts.withGuide ?? settings.withGuide; + const prefix = hasGuide ? `${styleText('grey', S_BAR)}\n` : ''; + + if (!this.isActive) { + if (this.silentExit || this.state === 'initial') { + return prefix; + } + const step = + this.exitCode === 0 + ? styleText('green', S_STEP_SUBMIT) + : this.exitCode === 1 + ? styleText('red', S_STEP_CANCEL) + : styleText('red', S_STEP_ERROR); + if (indicator === 'timer') { + return `${prefix}${step} ${this.message} ${this.getFormattedTimer()}`; + } else { + return `${prefix}${step} ${this.message}`; + } } - clearPrevMessage(); - _prevMessage = _message; - const frame = styleFn(frames[frameIndex]); + const frame = styleFn(frames[this.frameIndex]); + const message = this.message; let outputMessage: string; - if (isCI) { - outputMessage = `${frame} ${_message}...`; + outputMessage = `${frame} ${message}...`; } else if (indicator === 'timer') { - outputMessage = `${frame} ${_message} ${formatTimer(_origin)}`; - } else { - const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3); - outputMessage = `${frame} ${_message}${loadingDots}`; - } - - const wrapped = wrapAnsi(outputMessage, columns, { - hard: true, - trim: false, - }); - output.write(wrapped); - - frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; - // indicator increase by 1 every 8 frames - indicatorTimer = indicatorTimer < 4 ? indicatorTimer + 0.125 : 0; - }, delay); - }; - - const _stop = (msg = '', code = 0, silent: boolean = false): void => { - if (!isSpinnerActive) return; - isSpinnerActive = false; - clearInterval(loop); - clearPrevMessage(); - const step = - code === 0 - ? styleText('green', S_STEP_SUBMIT) - : code === 1 - ? styleText('red', S_STEP_CANCEL) - : styleText('red', S_STEP_ERROR); - _message = msg ?? _message; - if (!silent) { - if (indicator === 'timer') { - output.write(`${step} ${_message} ${formatTimer(_origin)}\n`); + outputMessage = `${frame} ${message} ${this.getFormattedTimer()}`; } else { - output.write(`${step} ${_message}\n`); + const loadingDots = '.'.repeat(Math.floor(this.indicatorTimer)).slice(0, 3); + outputMessage = `${frame} ${message}${loadingDots}`; } - } - clearHooks(); - unblock(); - }; - - const stop = (msg = ''): void => _stop(msg, 0); - const cancel = (msg = ''): void => _stop(msg, 1); - const error = (msg = ''): void => _stop(msg, 2); - // TODO (43081j): this will leave the initial S_BAR since we purposely - // don't erase that in `clearPrevMessage`. In future, we may want to treat - // `clear` as a special case and remove the bar too. - const clear = (): void => _stop('', 0, true); + return `${prefix}${outputMessage}`; + }, + }); - const message = (msg = ''): void => { - _message = removeTrailingDots(msg ?? _message); - }; + prompt.prompt(); return { - start, - stop, - message, - cancel, - error, - clear, + start: (msg?: string) => prompt.start(msg), + stop: (msg?: string) => prompt.stop(msg, 0), + message: (msg?: string) => { + if (msg !== undefined) { + prompt.message = msg; + } + }, + cancel: (msg: string = '') => prompt.stop(msg, 1), + error: (msg: string = '') => prompt.stop(msg, 2), + clear: () => prompt.stop('', 0, true), get isCancelled() { - return isCancelled; + return prompt.isCancelled; }, }; };