Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ export default class Prompt<TValue> {
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,
Expand Down
179 changes: 179 additions & 0 deletions packages/core/src/prompts/spinner.ts
Original file line number Diff line number Diff line change
@@ -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<undefined, SpinnerPrompt> {
indicator?: 'dots' | 'timer';
onCancel?: () => void;
cancelMessage?: string;
errorMessage?: string;
frames: string[];
delay: number;
styleFrame?: (frame: string) => string;
}

export default class SpinnerPrompt extends Prompt<undefined> {
#isCancelled = false;
#isActive = false;
#startTime: number = 0;
#frameIndex: number = 0;
#indicatorTimer: number = 0;
#intervalId: ReturnType<typeof setInterval> | 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);
}
}
151 changes: 151 additions & 0 deletions packages/core/test/prompts/spinner.test.ts
Original file line number Diff line number Diff line change
@@ -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]');
});
});
});
Loading
Loading