bktide
Version:
Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users
356 lines • 10 kB
JavaScript
/**
* Unified progress indicator system
* Provides both determinate (bar) and indeterminate (spinner) progress indicators
*/
import { COLORS, SYMBOLS } from './theme.js';
import { termWidth, truncate } from './width.js';
/**
* Check if output format is machine-readable
*/
function isMachineFormat(format) {
const f = (format || '').toLowerCase();
return f === 'json' || f === 'alfred';
}
/**
* Check if we should show progress indicators
*/
function shouldShowProgress(format) {
// Don't show in non-TTY environments
if (!process.stderr.isTTY)
return false;
// Don't show for machine formats
if (format && isMachineFormat(format))
return false;
// Don't show in CI environments
if (process.env.CI)
return false;
// Don't show if NO_COLOR is set (indicates non-interactive)
if (process.env.NO_COLOR)
return false;
return true;
}
/**
* Spinner for indeterminate progress
* Shows animated spinner with updating label
*/
class Spinner {
format;
interval;
frame = 0;
frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
label = '';
lastLineLength = 0;
isActive = false;
stream = process.stderr;
constructor(label, format) {
this.format = format;
if (label)
this.label = label;
}
shouldShow() {
return shouldShowProgress(this.format);
}
start() {
if (!this.shouldShow() || this.isActive)
return;
this.isActive = true;
this.interval = setInterval(() => {
this.render();
this.frame = (this.frame + 1) % this.frames.length;
}, 80);
}
update(_value, label) {
// For spinner, we only care about label updates
if (label !== undefined) {
this.label = label;
}
if (!this.isActive) {
this.start();
}
}
stop() {
if (!this.isActive)
return;
if (this.interval) {
clearInterval(this.interval);
this.interval = undefined;
}
this.clear();
this.isActive = false;
}
complete(message) {
this.stop();
if (message && this.shouldShow()) {
this.stream.write(COLORS.success(`${SYMBOLS.success} ${message}\n`));
}
}
fail(message) {
this.stop();
if (message && this.shouldShow()) {
this.stream.write(COLORS.error(`${SYMBOLS.error} ${message}\n`));
}
}
clear() {
if (!this.stream.isTTY)
return;
this.stream.write('\r' + ' '.repeat(this.lastLineLength) + '\r');
}
render() {
if (!this.shouldShow() || !this.isActive)
return;
const spinner = COLORS.info(this.frames[this.frame]);
const line = this.label ? `${spinner} ${this.label}` : spinner;
this.clear();
this.stream.write(line);
this.lastLineLength = line.length;
}
}
/**
* Progress bar for determinate progress
* Shows percentage and optional counts
*/
class Bar {
current = 0;
total;
barWidth = 30;
label;
lastLineLength = 0;
isActive = false;
stream = process.stderr;
format;
constructor(options) {
this.total = options.total || 100;
this.label = options.label;
this.barWidth = options.barWidth || 30;
this.format = options.format;
}
shouldShow() {
return shouldShowProgress(this.format);
}
start() {
if (!this.shouldShow() || this.isActive)
return;
this.isActive = true;
this.render();
}
update(value, label) {
if (!this.isActive) {
this.start();
}
// For bar, value should be a number
if (typeof value === 'number') {
this.current = Math.min(value, this.total);
}
if (label !== undefined) {
this.label = label;
}
if (this.shouldShow() && this.isActive) {
this.render();
}
}
stop() {
if (!this.isActive)
return;
this.clear();
this.isActive = false;
}
complete(message) {
if (!this.shouldShow())
return;
this.current = this.total;
this.render();
this.clear();
if (message) {
this.stream.write(COLORS.success(`${SYMBOLS.success} ${message}\n`));
}
this.isActive = false;
}
fail(message) {
this.stop();
if (message && this.shouldShow()) {
this.stream.write(COLORS.error(`${SYMBOLS.error} ${message}\n`));
}
}
clear() {
if (!this.stream.isTTY)
return;
this.stream.write('\r' + ' '.repeat(this.lastLineLength) + '\r');
}
render() {
if (!this.shouldShow() || !this.isActive)
return;
const percentage = Math.round((this.current / this.total) * 100);
const filledLength = Math.round((this.current / this.total) * this.barWidth);
const emptyLength = this.barWidth - filledLength;
const filled = '█'.repeat(filledLength);
const empty = '░'.repeat(emptyLength);
const bar = `[${filled}${empty}]`;
const parts = [];
if (this.label) {
const maxLabelWidth = Math.max(20, termWidth() - this.barWidth - 20);
parts.push(truncate(this.label, maxLabelWidth));
}
parts.push(bar);
parts.push(`${percentage}%`);
parts.push(`(${this.current}/${this.total})`);
const line = parts.join(' ');
this.clear();
this.stream.write(line);
this.lastLineLength = line.length;
}
}
/**
* No-op progress for non-interactive environments
*/
class NoOpProgress {
update() { }
stop() { }
complete() { }
fail() { }
}
/**
* Main Progress API - factory methods for creating progress indicators
*/
export class Progress {
/**
* Create a spinner (indeterminate progress)
* Use for operations of unknown duration
*/
static spinner(label, options) {
if (!shouldShowProgress(options?.format)) {
return new NoOpProgress();
}
const spinner = new Spinner(label, options?.format);
if (label) {
spinner.start();
}
return spinner;
}
/**
* Create a progress bar (determinate progress)
* Use when you know the total number of items
*/
static bar(options) {
if (!shouldShowProgress(options.format)) {
return new NoOpProgress();
}
const bar = new Bar(options);
bar.start();
return bar;
}
/**
* Smart factory that creates appropriate progress type
* Creates bar if total is provided, spinner otherwise
*/
static create(options) {
if (!options) {
return Progress.spinner();
}
if (options.total !== undefined && options.total > 0) {
return Progress.bar(options);
}
return Progress.spinner(options.label, options);
}
}
/**
* Helper for async operations with progress tracking
*/
export async function withProgress(operation, options) {
const progress = Progress.create(options);
try {
const result = await operation(progress);
progress.complete(options?.successMessage);
return result;
}
catch (error) {
progress.fail(error instanceof Error ? error.message : 'Operation failed');
throw error;
}
}
// ============================================================================
// Legacy API - for backward compatibility during migration
// ============================================================================
/**
* @deprecated Use Progress.bar() instead
*/
export class ProgressBar {
progress;
constructor(options) {
this.progress = Progress.bar({
total: options.total,
label: options.label,
format: options.format
});
}
start() {
// Already started in constructor
}
update(value, label) {
this.progress.update(value, label);
}
stop() {
this.progress.stop();
}
complete(message) {
this.progress.complete(message);
}
}
/**
* @deprecated Use Progress.spinner() instead
*/
export class IndeterminateProgress {
progress;
constructor(label, format) {
this.progress = Progress.spinner(label, { format });
}
start() {
// Already started if label provided
}
updateLabel(label) {
this.progress.update(label, label);
}
stop() {
this.progress.stop();
}
complete(message) {
this.progress.complete(message);
}
}
/**
* @deprecated Use withProgress() instead
*/
export async function withCountedProgress(items, operation, options = {}) {
if (!items || items.length === 0) {
return;
}
const progress = Progress.bar({
total: items.length,
label: options.label || 'Processing',
format: options.format
});
try {
for (let i = 0; i < items.length; i++) {
const label = options.itemLabel ?
options.itemLabel(items[i], i) :
`Processing item ${i + 1}/${items.length}`;
progress.update(i, label);
await operation(items[i], i);
}
progress.update(items.length, 'Complete');
const completeMessage = options.onComplete ?
options.onComplete(items.length) :
`Processed ${items.length} items`;
progress.complete(completeMessage);
}
catch (error) {
progress.stop();
throw error;
}
}
/**
* @deprecated Use withProgress() instead
*/
export async function withIndeterminateProgress(operation, label, format, successMessage) {
return withProgress(operation, { label, format, successMessage });
}
//# sourceMappingURL=progress.js.map