/ src / download / progress.ts
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  }