Skip to content

Commit 426c428

Browse files
committed
fix(spinner): prevent duplicate message in start() method
The start() method was printing the message twice: 1. Via #updateSpinnerText() which sets super.text 2. Via #apply('start', args) which passes text to yocto-spinner.start() Fixed by passing empty args array to #apply() since text is already set.
1 parent 9449c74 commit 426c428

1 file changed

Lines changed: 179 additions & 4 deletions

File tree

src/spinner.ts

Lines changed: 179 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import type { Writable } from 'stream'
99
import { getCI } from '#env/ci'
1010
import { generateSocketSpinnerFrames } from './effects/pulse-frames'
1111
import type {
12+
ShimmerColor,
1213
ShimmerColorGradient,
14+
ShimmerColorRgb,
1315
ShimmerConfig,
1416
ShimmerDirection,
1517
ShimmerState,
@@ -101,7 +103,7 @@ function isRgbTuple(value: ColorValue): value is ColorRgb {
101103
* @param color - Color name or RGB tuple
102104
* @returns RGB tuple with values 0-255
103105
*/
104-
function toRgb(color: ColorValue): ColorRgb {
106+
export function toRgb(color: ColorValue): ColorRgb {
105107
if (isRgbTuple(color)) {
106108
return color
107109
}
@@ -163,6 +165,9 @@ export type Spinner = {
163165
/** Whether spinner is currently animating */
164166
get isSpinning(): boolean
165167

168+
/** Get current shimmer state (enabled/disabled and configuration) */
169+
get shimmerState(): ShimmerInfo | undefined
170+
166171
/** Clear the current line without stopping the spinner */
167172
clear(): Spinner
168173

@@ -227,6 +232,13 @@ export type Spinner = {
227232
/** Increment progress by specified amount (default: 1) */
228233
progressStep(amount?: number): Spinner
229234

235+
/** Push current options onto stack and apply new temporary options */
236+
pushOptions(
237+
options: Partial<Pick<SpinnerOptions, 'color' | 'shimmer'>>,
238+
): Spinner
239+
/** Pop and restore previous options from stack */
240+
popOptions(): Spinner
241+
230242
/** Toggle shimmer effect on/off */
231243
shimmer(enabled: boolean): Spinner
232244
/** Update shimmer configuration or set direction */
@@ -474,6 +486,10 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
474486
#progress?: ProgressInfo | undefined
475487
#shimmer?: ShimmerInfo | undefined
476488
#shimmerSavedConfig?: ShimmerInfo | undefined
489+
#optionsStack: Array<{
490+
color: ColorRgb
491+
shimmer: ShimmerInfo | undefined
492+
}> = []
477493

478494
constructor(options?: SpinnerOptions | undefined) {
479495
const opts = { __proto__: null, ...options } as SpinnerOptions
@@ -583,6 +599,20 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
583599
super.color = isRgbTuple(value) ? value : toRgb(value)
584600
}
585601

602+
// Getter to expose current shimmer state.
603+
get shimmerState(): ShimmerInfo | undefined {
604+
if (!this.#shimmer) {
605+
return undefined
606+
}
607+
return {
608+
color: this.#shimmer.color,
609+
currentDir: this.#shimmer.currentDir,
610+
mode: this.#shimmer.mode,
611+
speed: this.#shimmer.speed,
612+
step: this.#shimmer.step,
613+
} as ShimmerInfo
614+
}
615+
586616
/**
587617
* Apply a yocto-spinner method and update logger state.
588618
* Handles text normalization, extra arguments, and logger tracking.
@@ -946,6 +976,119 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
946976
return this
947977
}
948978

979+
/**
980+
* Push current spinner options onto the stack and apply new temporary options.
981+
* Use `popOptions()` to restore the previous options.
982+
* Supports nested calls - each push must be paired with a pop.
983+
*
984+
* @param options - Temporary options to apply (color, shimmer)
985+
* @returns This spinner for chaining
986+
*
987+
* @example
988+
* ```ts
989+
* const spinner = Spinner({ color: 'cyan' })
990+
* spinner.start('Processing...')
991+
*
992+
* // Temporarily change to red for error handling
993+
* spinner.pushOptions({ color: 'red' })
994+
* spinner.text('Handling error')
995+
* // ... do error work ...
996+
* spinner.popOptions() // Restore cyan
997+
*
998+
* // Nested example
999+
* spinner.pushOptions({ color: 'yellow' })
1000+
* spinner.pushOptions({ color: 'red' })
1001+
* spinner.popOptions() // Back to yellow
1002+
* spinner.popOptions() // Back to cyan
1003+
* ```
1004+
*/
1005+
pushOptions(
1006+
options: Partial<Pick<SpinnerOptions, 'color' | 'shimmer'>>,
1007+
): Spinner {
1008+
const opts = { __proto__: null, ...options } as Partial<
1009+
Pick<SpinnerOptions, 'color' | 'shimmer'>
1010+
>
1011+
1012+
// Save current state
1013+
const savedState = {
1014+
color: this.color,
1015+
shimmer: this.shimmerState
1016+
? {
1017+
color: this.#shimmer!.color,
1018+
currentDir: this.#shimmer!.currentDir,
1019+
mode: this.#shimmer!.mode,
1020+
speed: this.#shimmer!.speed,
1021+
step: this.#shimmer!.step,
1022+
}
1023+
: undefined,
1024+
}
1025+
this.#optionsStack.push(savedState)
1026+
1027+
// Apply new options
1028+
if (opts.color !== undefined) {
1029+
this.color = toRgb(opts.color)
1030+
}
1031+
if (opts.shimmer !== undefined) {
1032+
this.shimmer(opts.shimmer)
1033+
}
1034+
1035+
return this as unknown as Spinner
1036+
}
1037+
1038+
/**
1039+
* Pop and restore the previous spinner options from the stack.
1040+
* Must be paired with a previous `pushOptions()` call.
1041+
* If stack is empty, this method does nothing.
1042+
*
1043+
* @returns This spinner for chaining
1044+
*
1045+
* @example
1046+
* ```ts
1047+
* spinner.pushOptions({ color: 'red' })
1048+
* // ... do work with red spinner ...
1049+
* spinner.popOptions() // Restore previous color
1050+
* ```
1051+
*/
1052+
popOptions(): Spinner {
1053+
const savedState = this.#optionsStack.pop()
1054+
if (!savedState) {
1055+
// Stack is empty, nothing to restore
1056+
return this as unknown as Spinner
1057+
}
1058+
1059+
// Restore color
1060+
this.color = savedState.color
1061+
1062+
// Restore shimmer state
1063+
if (savedState.shimmer) {
1064+
const shimmerColor = savedState.shimmer.color
1065+
let restoredColor: ShimmerColor | ShimmerColorGradient | undefined
1066+
if (shimmerColor === COLOR_INHERIT) {
1067+
restoredColor = COLOR_INHERIT
1068+
} else if (Array.isArray(shimmerColor)) {
1069+
// Check if it's a gradient (array of arrays) or single RGB
1070+
if (shimmerColor.length > 0 && Array.isArray(shimmerColor[0])) {
1071+
restoredColor = shimmerColor as ShimmerColorGradient
1072+
} else {
1073+
restoredColor = shimmerColor as unknown as ShimmerColorRgb
1074+
}
1075+
} else if (typeof shimmerColor === 'string') {
1076+
// It's a named color, convert to RGB
1077+
restoredColor = toRgb(shimmerColor as ColorName)
1078+
}
1079+
this.shimmer({
1080+
color: restoredColor,
1081+
dir: savedState.shimmer.mode,
1082+
speed: savedState.shimmer.speed,
1083+
})
1084+
} else {
1085+
// Shimmer was disabled before
1086+
this.shimmer(false)
1087+
}
1088+
1089+
return this as unknown as Spinner
1090+
}
1091+
9491092
/**
9501093
* Start the spinner animation with optional text.
9511094
* Begins displaying the animated spinner on stderr.
@@ -975,7 +1118,9 @@ export function Spinner(options?: SpinnerOptions | undefined): Spinner {
9751118
}
9761119

9771120
this.#updateSpinnerText()
978-
return this.#apply('start', args)
1121+
// Don't pass text to yocto-spinner.start() since we already set it via #updateSpinnerText().
1122+
// Passing args would cause duplicate message output.
1123+
return this.#apply('start', [])
9791124
}
9801125

9811126
/**
@@ -1365,6 +1510,12 @@ export type WithSpinnerOptions<T> = {
13651510
* If not provided, operation runs without spinner.
13661511
*/
13671512
spinner?: Spinner | undefined
1513+
/**
1514+
* Optional spinner options to apply during the operation.
1515+
* These options will be pushed when the operation starts and popped when it completes.
1516+
* Supports color and shimmer configuration.
1517+
*/
1518+
withOptions?: Partial<Pick<SpinnerOptions, 'color' | 'shimmer'>> | undefined
13681519
}
13691520

13701521
/**
@@ -1407,7 +1558,7 @@ export type WithSpinnerOptions<T> = {
14071558
export async function withSpinner<T>(
14081559
options: WithSpinnerOptions<T>,
14091560
): Promise<T> {
1410-
const { message, operation, spinner } = {
1561+
const { message, operation, spinner, withOptions } = {
14111562
__proto__: null,
14121563
...options,
14131564
} as WithSpinnerOptions<T>
@@ -1416,11 +1567,20 @@ export async function withSpinner<T>(
14161567
return await operation()
14171568
}
14181569

1570+
// Push options if provided
1571+
if (withOptions) {
1572+
spinner.pushOptions(withOptions)
1573+
}
1574+
14191575
spinner.start(message)
14201576
try {
14211577
return await operation()
14221578
} finally {
14231579
spinner.stop()
1580+
// Pop options if they were pushed
1581+
if (withOptions) {
1582+
spinner.popOptions()
1583+
}
14241584
}
14251585
}
14261586

@@ -1500,6 +1660,12 @@ export type WithSpinnerSyncOptions<T> = {
15001660
* If not provided, operation runs without spinner.
15011661
*/
15021662
spinner?: Spinner | undefined
1663+
/**
1664+
* Optional spinner options to apply during the operation.
1665+
* These options will be pushed when the operation starts and popped when it completes.
1666+
* Supports color and shimmer configuration.
1667+
*/
1668+
withOptions?: Partial<Pick<SpinnerOptions, 'color' | 'shimmer'>> | undefined
15031669
}
15041670

15051671
/**
@@ -1531,7 +1697,7 @@ export type WithSpinnerSyncOptions<T> = {
15311697
* ```
15321698
*/
15331699
export function withSpinnerSync<T>(options: WithSpinnerSyncOptions<T>): T {
1534-
const { message, operation, spinner } = {
1700+
const { message, operation, spinner, withOptions } = {
15351701
__proto__: null,
15361702
...options,
15371703
} as WithSpinnerSyncOptions<T>
@@ -1540,10 +1706,19 @@ export function withSpinnerSync<T>(options: WithSpinnerSyncOptions<T>): T {
15401706
return operation()
15411707
}
15421708

1709+
// Push options if provided
1710+
if (withOptions) {
1711+
spinner.pushOptions(withOptions)
1712+
}
1713+
15431714
spinner.start(message)
15441715
try {
15451716
return operation()
15461717
} finally {
15471718
spinner.stop()
1719+
// Pop options if they were pushed
1720+
if (withOptions) {
1721+
spinner.popOptions()
1722+
}
15481723
}
15491724
}

0 commit comments

Comments
 (0)