progress.ts
1 /** 2 * Download progress display: terminal progress bars, status updates. 3 */ 4 5 import { styleText } from 'node:util'; 6 7 export interface ProgressBar { 8 update(current: number, total: number, label?: string): void; 9 complete(success: boolean, message?: string): void; 10 fail(error: string): void; 11 } 12 13 /** 14 * Format bytes as human-readable string (KB, MB, GB). 15 */ 16 export function formatBytes(bytes: number): string { 17 if (bytes === 0) return '0 B'; 18 const k = 1024; 19 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; 20 const i = Math.floor(Math.log(bytes) / Math.log(k)); 21 return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; 22 } 23 24 /** 25 * Format milliseconds as human-readable duration. 26 */ 27 export function formatDuration(ms: number): string { 28 if (ms < 1000) return `${ms}ms`; 29 const seconds = Math.floor(ms / 1000); 30 if (seconds < 60) return `${seconds}s`; 31 const minutes = Math.floor(seconds / 60); 32 if (minutes < 60) { 33 const remainingSeconds = seconds % 60; 34 return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; 35 } 36 const hours = Math.floor(minutes / 60); 37 const remainingMinutes = minutes % 60; 38 return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; 39 } 40 41 /** 42 * Create a simple progress bar for terminal display. 43 */ 44 export function createProgressBar(filename: string, index: number, total: number): ProgressBar { 45 const prefix = styleText('dim', `[${index + 1}/${total}]`); 46 const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename; 47 48 return { 49 update(current: number, totalBytes: number, label?: string) { 50 const percent = totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0; 51 const bar = createBar(percent); 52 const size = totalBytes > 0 ? formatBytes(totalBytes) : ''; 53 const extra = label ? ` ${label}` : ''; 54 process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`); 55 }, 56 complete(success: boolean, message?: string) { 57 const icon = success ? styleText('green', '✓') : styleText('red', '✗'); 58 const msg = message ? ` ${styleText('dim', message)}` : ''; 59 process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`); 60 }, 61 fail(error: string) { 62 process.stderr.write(`\r${prefix} ${styleText('red', '✗')} ${truncatedName} ${styleText('red', error)}\n`); 63 }, 64 }; 65 } 66 67 /** 68 * Create a progress bar string. 69 */ 70 function createBar(percent: number, width: number = 20): string { 71 const filled = Math.round((percent / 100) * width); 72 const empty = width - filled; 73 return styleText('cyan', '█'.repeat(filled)) + styleText('dim', '░'.repeat(empty)); 74 } 75 76 /** 77 * Multi-file download progress tracker. 78 */ 79 export class DownloadProgressTracker { 80 private completed = 0; 81 private failed = 0; 82 private skipped = 0; 83 private total: number; 84 private startTime: number; 85 private verbose: boolean; 86 87 constructor(total: number, verbose: boolean = true) { 88 this.total = total; 89 this.startTime = Date.now(); 90 this.verbose = verbose; 91 } 92 93 onFileStart(filename: string, index: number): ProgressBar | null { 94 if (!this.verbose) return null; 95 return createProgressBar(filename, index, this.total); 96 } 97 98 onFileComplete(success: boolean, skipped: boolean = false): void { 99 if (skipped) { 100 this.skipped++; 101 } else if (success) { 102 this.completed++; 103 } else { 104 this.failed++; 105 } 106 } 107 108 getSummary(): string { 109 const elapsed = formatDuration(Date.now() - this.startTime); 110 const parts: string[] = []; 111 112 if (this.completed > 0) { 113 parts.push(styleText('green', `${this.completed} downloaded`)); 114 } 115 if (this.skipped > 0) { 116 parts.push(styleText('yellow', `${this.skipped} skipped`)); 117 } 118 if (this.failed > 0) { 119 parts.push(styleText('red', `${this.failed} failed`)); 120 } 121 122 return `${parts.join(', ')} in ${elapsed}`; 123 } 124 125 finish(): void { 126 if (this.verbose) { 127 process.stderr.write(`\n${styleText('bold', 'Download complete:')} ${this.getSummary()}\n`); 128 } 129 } 130 }