143 lines
5.1 KiB
JavaScript
143 lines
5.1 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Google Inc.
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
// Module-level active session. Concurrent bars share a session; a new session
|
|
// starts whenever no bar is currently active (or the stream changes).
|
|
let activeBar = null;
|
|
/**
|
|
* Manages one or more concurrent progress bars on separate terminal lines.
|
|
* Use {@link ProgressBar.createBarTicker} to add a bar; concurrent calls
|
|
* automatically share a session so bars don't overwrite each other.
|
|
*
|
|
* @internal
|
|
*/
|
|
export class ProgressBar {
|
|
#stream;
|
|
#totalRows = 0;
|
|
#doneCount = 0;
|
|
#bars = [];
|
|
#renderScheduled = false;
|
|
#lastRenderTime = 0;
|
|
#allDone = false;
|
|
constructor(stream) {
|
|
this.#stream = stream;
|
|
}
|
|
/**
|
|
* Returns a `tick(delta)` function for a new progress bar. If another bar
|
|
* is already active on the same stream, the new bar appears on the next line
|
|
* and both update in place. The trailing newline is emitted automatically
|
|
* once every bar in the session has reached 100%.
|
|
*/
|
|
static createBarTicker(title, total, stream = process.stderr) {
|
|
// Start a fresh session when none is active or the stream changed.
|
|
if (!activeBar || activeBar.#stream !== stream) {
|
|
activeBar = new ProgressBar(stream);
|
|
}
|
|
return activeBar.#addBar(title, total);
|
|
}
|
|
#claimRow() {
|
|
const row = this.#totalRows++;
|
|
if (row > 0 && this.#stream.isTTY) {
|
|
// Cursor is parked at col 0 of the previous bottom row; \n opens the
|
|
// new line and moves the cursor there, establishing the new bottom row.
|
|
this.#stream.write('\n');
|
|
}
|
|
return row;
|
|
}
|
|
#addBar(title, total) {
|
|
const row = this.#claimRow();
|
|
this.#bars.push({
|
|
title,
|
|
total,
|
|
downloaded: 0,
|
|
startTime: 0,
|
|
lastRendered: '',
|
|
});
|
|
let doneNotified = false;
|
|
return (delta) => {
|
|
const bar = this.#bars[row];
|
|
if (bar.startTime === 0) {
|
|
bar.startTime = Date.now();
|
|
}
|
|
bar.downloaded += delta;
|
|
const isDone = bar.downloaded >= bar.total;
|
|
if (isDone || Date.now() - this.#lastRenderTime >= 100) {
|
|
this.#scheduleRender();
|
|
}
|
|
if (isDone && !doneNotified) {
|
|
doneNotified = true;
|
|
this.#onBarDone();
|
|
}
|
|
};
|
|
}
|
|
// Defers to a microtask so multiple ticks in the same turn share one render
|
|
// pass and the cursor only moves once.
|
|
#scheduleRender() {
|
|
if (!this.#renderScheduled) {
|
|
this.#renderScheduled = true;
|
|
queueMicrotask(() => {
|
|
this.#renderScheduled = false;
|
|
this.#lastRenderTime = Date.now();
|
|
this.#renderAll();
|
|
});
|
|
}
|
|
}
|
|
#computeRendered(row) {
|
|
const bar = this.#bars[row];
|
|
const ratio = Math.min(1, Math.max(0, bar.downloaded / bar.total));
|
|
const percent = Math.round(ratio * 100);
|
|
const elapsedMs = bar.startTime > 0 ? Date.now() - bar.startTime : 0;
|
|
const rate = elapsedMs > 0 ? bar.downloaded / elapsedMs : 0; // bytes/ms
|
|
const etaSec = rate > 0 ? (bar.total - bar.downloaded) / rate / 1000 : 0;
|
|
const width = 20;
|
|
const completeCount = Math.round(width * ratio);
|
|
const barStr = '='.repeat(completeCount) + ' '.repeat(width - completeCount);
|
|
const status = ratio >= 1 ? 'unpacking' : `${percent}% ${etaSec.toFixed(1)}s`;
|
|
return `${bar.title} [${barStr}] ${status} `;
|
|
}
|
|
// Invariant: cursor is at col 0 of the bottom row before and after this call.
|
|
// Single top-down pass: go up to bar 0, render each bar, come back to bottom.
|
|
#renderAll() {
|
|
if (!this.#stream.isTTY) {
|
|
return;
|
|
}
|
|
const N = this.#totalRows;
|
|
if (N === 0) {
|
|
return;
|
|
}
|
|
// Move cursor to top of bar area.
|
|
if (N > 1) {
|
|
this.#stream.moveCursor(0, -(N - 1));
|
|
}
|
|
for (let row = 0; row < N; row++) {
|
|
const rendered = this.#computeRendered(row);
|
|
if (rendered !== this.#bars[row].lastRendered) {
|
|
this.#stream.cursorTo(0);
|
|
this.#stream.write(rendered);
|
|
this.#stream.clearLine(1);
|
|
this.#bars[row].lastRendered = rendered;
|
|
}
|
|
if (row < N - 1) {
|
|
this.#stream.moveCursor(0, 1);
|
|
}
|
|
}
|
|
// Park cursor at col 0 of the bottom row.
|
|
this.#stream.cursorTo(0);
|
|
if (this.#allDone) {
|
|
activeBar = null;
|
|
this.#stream.write('\n');
|
|
}
|
|
}
|
|
#onBarDone() {
|
|
this.#doneCount++;
|
|
if (this.#doneCount >= this.#totalRows && this.#totalRows > 0) {
|
|
this.#allDone = true;
|
|
// Don't emit \n here — the pending render microtask hasn't run yet.
|
|
// #renderAll() will emit it after the final pass so the cursor is
|
|
// already past the last bar line before the newline moves it.
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=ProgressBar.js.map
|