Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/empty-bananas-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": patch
"@clack/core": patch
Comment on lines +2 to +3
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be okay as an internal refactor, but since this technically alters the raw-mode behavior we might want to do this in a minor to be safe?

---

Rework the spinner prompt to use the `Prompt` base class for rendering.
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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 @@ -273,7 +273,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
183 changes: 183 additions & 0 deletions packages/core/src/prompts/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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;

if (Number.isFinite(this.#delay)) {
this.#intervalId = setInterval(() => this.#onInterval(), this.#delay);
} else {
this.render();
}

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';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do this.state = 'submit' when the exitCode === 0? Currently this would treat every exit as 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(0);
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