-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprogress.ts
More file actions
300 lines (274 loc) · 7.94 KB
/
progress.ts
File metadata and controls
300 lines (274 loc) · 7.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
/**
* @fileoverview Progress bar utilities for CLI applications.
* Provides various progress indicators including bars, percentages, and spinners.
*/
import process from 'node:process'
import colors from '../external/yoctocolors-cjs'
import { repeatString, stripAnsi } from '../strings'
export interface ProgressBarOptions {
/**
* Width of the progress bar in characters.
* @default 40
*/
width?: number | undefined
/**
* Format template for progress bar display.
* Available tokens: `:bar`, `:percent`, `:current`, `:total`, `:elapsed`, `:eta`.
* Custom tokens can be passed via the `tokens` parameter in `update()` or `tick()`.
* @default ':bar :percent :current/:total'
* @example
* ```ts
* format: ':bar :percent :current/:total :eta'
* ```
*/
format?: string | undefined
/**
* Character(s) to use for completed portion of bar.
* @default '█'
*/
complete?: string | undefined
/**
* Character(s) to use for incomplete portion of bar.
* @default '░'
*/
incomplete?: string | undefined
/**
* Character(s) to use for the head of the progress bar.
* @default ''
*/
head?: string | undefined
/**
* Clear the progress bar when complete.
* @default false
*/
clear?: boolean | undefined
/**
* Minimum time between renders in milliseconds.
* ~60fps = 16ms throttle.
* @default 16
*/
renderThrottle?: number | undefined
/**
* Stream to write progress bar output to.
* @default process.stderr
*/
stream?: NodeJS.WriteStream | undefined
/**
* Color to apply to the completed portion of the bar.
* @default 'cyan'
*/
color?: 'cyan' | 'green' | 'yellow' | 'blue' | 'magenta' | undefined
}
export class ProgressBar {
private current: number = 0
private total: number
private startTime: number
private lastRender: number = 0
private stream: NodeJS.WriteStream
private options: Required<ProgressBarOptions>
private terminated: boolean = false
private lastDrawnWidth: number = 0
/**
* Create a new progress bar instance.
*
* @param total - Total number of units for the progress bar
* @param options - Configuration options for the progress bar
*
* @example
* ```ts
* const bar = new ProgressBar(100, {
* width: 50,
* format: ':bar :percent :current/:total :eta',
* color: 'green'
* })
* ```
*/
constructor(total: number, options?: ProgressBarOptions) {
this.total = total
this.startTime = Date.now()
this.stream = options?.stream || process.stderr
this.options = {
width: 40,
format: ':bar :percent :current/:total',
complete: '█',
incomplete: '░',
head: '',
clear: false,
// ~60fps.
renderThrottle: 16,
stream: this.stream,
color: 'cyan',
...options,
}
}
/**
* Update progress to a specific value and redraw the bar.
* Updates are throttled to prevent excessive rendering (default ~60fps).
*
* @param current - Current progress value (will be clamped to total)
* @param tokens - Optional custom tokens to replace in format string
*
* @example
* ```ts
* bar.update(50)
* bar.update(75, { status: 'Processing...' })
* ```
*/
update(current: number, tokens?: Record<string, unknown>): void {
if (this.terminated) {
return
}
this.current = Math.min(current, this.total)
// Throttle rendering
const now = Date.now()
if (
now - this.lastRender < (this.options.renderThrottle ?? 16) &&
this.current < this.total
) {
return
}
this.lastRender = now
this.render(tokens)
if (this.current >= this.total) {
this.terminate()
}
}
/**
* Increment progress by a specified amount.
* Convenience method for `update(current + amount)`.
*
* @param amount - Amount to increment by
* @param tokens - Optional custom tokens to replace in format string
* @default amount 1
*
* @example
* ```ts
* bar.tick() // Increment by 1
* bar.tick(5) // Increment by 5
* bar.tick(1, { file: 'data.json' })
* ```
*/
tick(amount: number = 1, tokens?: Record<string, unknown>): void {
this.update(this.current + amount, tokens)
}
/**
* Render the progress bar.
*/
private render(tokens?: Record<string, unknown>): void {
const colorName = this.options.color ?? 'cyan'
const colorFn = colors[colorName] || ((s: string) => s)
// Calculate values
const percent =
this.total === 0 ? 0 : Math.floor((this.current / this.total) * 100)
const elapsed = Date.now() - this.startTime
const eta =
this.current === 0
? 0
: (elapsed / this.current) * (this.total - this.current)
// Build bar
const availableWidth = this.options.width ?? 40
const filledWidth =
this.total === 0
? 0
: Math.min(
availableWidth,
Math.floor((this.current / this.total) * availableWidth),
)
const emptyWidth = Math.max(0, availableWidth - filledWidth)
const filled = repeatString(this.options.complete ?? '█', filledWidth)
const empty = repeatString(this.options.incomplete ?? '░', emptyWidth)
const bar = colorFn(filled) + empty
// Format output
let output = this.options.format ?? ':bar :percent :current/:total'
output = output.replace(':bar', bar)
output = output.replace(':percent', `${percent}%`)
output = output.replace(':current', String(this.current))
output = output.replace(':total', String(this.total))
output = output.replace(':elapsed', this.formatTime(elapsed))
output = output.replace(':eta', this.formatTime(eta))
// Replace custom tokens
if (tokens) {
for (const [key, value] of Object.entries(tokens)) {
output = output.replace(`:${key}`, String(value))
}
}
// Clear line and write
this.clearLine()
this.stream.write(output)
this.lastDrawnWidth = stripAnsi(output).length
}
/**
* Clear the current line.
*/
private clearLine(): void {
if (this.stream.isTTY) {
this.stream.cursorTo(0)
this.stream.clearLine(0)
} else if (this.lastDrawnWidth > 0) {
this.stream.write(`\r${repeatString(' ', this.lastDrawnWidth)}\r`)
}
}
/**
* Format time in seconds to human readable.
*/
private formatTime(ms: number): string {
// Clamp negatives (can happen when current > total due to over-ticking
// or clock skew) to 0 so we don't render "-1m59s".
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) {
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}m${remainingSeconds}s`
}
/**
* Terminate the progress bar and optionally clear it.
* Called automatically when progress reaches 100%.
* If `clear` option is true, removes the bar from terminal.
* Otherwise, moves to next line to preserve the final state.
*/
terminate(): void {
if (this.terminated) {
return
}
this.terminated = true
if (this.options.clear) {
this.clearLine()
} else {
this.stream.write('\n')
}
}
}
/**
* Create a simple progress indicator without a graphical bar.
* Returns a formatted string showing progress as percentage and fraction.
*
* @param current - Current progress value
* @param total - Total progress value
* @param label - Optional label prefix
* @returns Formatted progress indicator string
*
* @example
* ```ts
* createProgressIndicator(50, 100)
* // Returns: '[50%] 50/100'
*
* createProgressIndicator(3, 10, 'Files')
* // Returns: 'Files: [30%] 3/10'
* ```
*/
export function createProgressIndicator(
current: number,
total: number,
label?: string | undefined,
): string {
const percent = total === 0 ? 0 : Math.floor((current / total) * 100)
const progress = `${current}/${total}`
let output = ''
if (label) {
output += `${label}: `
}
output += `${colors.cyan(`[${percent}%]`)} ${progress}`
return output
}