UNPKG

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
/** * 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