import * as plugins from './smartlog-source-interactive.plugins.js'; /** * Utility to detect if the environment is interactive * Checks for TTY capability and common CI environment variables */ const isInteractive = () => { try { return Boolean( // Check TTY capability process.stdout && process.stdout.isTTY && // Additional checks for non-interactive environments !('CI' in process.env) && !process.env.GITHUB_ACTIONS && !process.env.JENKINS_URL && !process.env.GITLAB_CI && !process.env.TRAVIS && !process.env.CIRCLECI && process.env.TERM !== 'dumb' ); } catch (e) { // If any error occurs (e.g., in browser environments without process), // assume a non-interactive environment to be safe return false; } }; // Helper to log messages in non-interactive mode const logMessage = (message: string, prefix = '') => { if (prefix) { console.log(`${prefix} ${message}`); } else { console.log(message); } }; // Spinner frames and styles const spinnerFrames = { dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], line: ['|', '/', '-', '\\'], star: ['✶', '✸', '✹', '✺', '✹', '✷'], simple: ['-', '\\', '|', '/'] }; // Color names mapping to ANSI color codes const colors = { black: '\u001b[30m', red: '\u001b[31m', green: '\u001b[32m', yellow: '\u001b[33m', blue: '\u001b[34m', magenta: '\u001b[35m', cyan: '\u001b[36m', white: '\u001b[37m', gray: '\u001b[90m', reset: '\u001b[0m' }; /** * A class for creating interactive spinners * Automatically handles non-interactive environments */ export class SmartlogSourceInteractive { private textContent: string = 'loading'; private currentFrame: number = 0; private interval: NodeJS.Timeout | null = null; private started: boolean = false; private spinnerStyle: keyof typeof spinnerFrames = 'dots'; private color: keyof typeof colors = 'cyan'; private frames: string[]; private frameInterval: number = 80; private interactive: boolean; constructor() { this.frames = spinnerFrames[this.spinnerStyle]; this.interactive = isInteractive(); } /** * Sets the text for the spinner and starts it if not already started */ public text(textArg: string) { this.textContent = textArg; if (!this.interactive) { // In non-interactive mode, just log the message with a loading indicator logMessage(textArg, '[Loading]'); this.started = true; return; } if (!this.started) { this.started = true; this.start(); } else { this.renderFrame(); } } /** * Starts the spinner animation */ private start() { if (!this.interactive) { return; // No animation in non-interactive mode } if (this.interval) { clearInterval(this.interval); } this.renderFrame(); this.interval = setInterval(() => { this.currentFrame = (this.currentFrame + 1) % this.frames.length; this.renderFrame(); }, this.frameInterval); } /** * Renders the current frame of the spinner */ private renderFrame() { if (!this.started || !this.interactive) return; const frame = this.frames[this.currentFrame]; const colorCode = colors[this.color]; const resetCode = colors.reset; // Only use ANSI escape codes in interactive mode process.stdout.write('\r\x1b[2K'); // Clear the current line process.stdout.write(`${colorCode}${frame}${resetCode} ${this.textContent}`); } /** * Stops the spinner */ public stop() { // Always clear the interval even in non-interactive mode // This prevents memory leaks in tests and long-running applications if (this.interval) { clearInterval(this.interval); this.interval = null; } if (!this.interactive) { return; // No need to clear the line in non-interactive mode } process.stdout.write('\r\x1b[2K'); // Clear the current line } /** * Marks the spinner as successful and optionally displays a success message */ public finishSuccess(textArg?: string) { const message = textArg || this.textContent; // Always stop the spinner first to clean up intervals this.stop(); if (!this.interactive) { logMessage(message, '[Success]'); } else { const successSymbol = colors.green + '✓' + colors.reset; process.stdout.write(`${successSymbol} ${message}\n`); } this.started = false; } /** * Marks the spinner as failed and optionally displays a failure message */ public finishFail(textArg?: string) { const message = textArg || this.textContent; // Always stop the spinner first to clean up intervals this.stop(); if (!this.interactive) { logMessage(message, '[Failed]'); } else { const failSymbol = colors.red + '✗' + colors.reset; process.stdout.write(`${failSymbol} ${message}\n`); } this.started = false; } /** * Marks the current spinner as successful and starts a new one */ public successAndNext(textArg: string) { this.finishSuccess(); this.text(textArg); } /** * Marks the current spinner as failed and starts a new one */ public failAndNext(textArg: string) { this.finishFail(); this.text(textArg); } /** * Sets the spinner style */ public setSpinnerStyle(style: keyof typeof spinnerFrames) { this.spinnerStyle = style; this.frames = spinnerFrames[style]; return this; } /** * Sets the spinner color */ public setColor(colorName: keyof typeof colors) { if (colorName in colors) { this.color = colorName; } return this; } /** * Sets the animation speed in milliseconds */ public setSpeed(ms: number) { this.frameInterval = ms; if (this.started) { this.stop(); this.start(); } return this; } /** * Gets the current started state */ public isStarted() { return this.started; } } export interface IProgressBarOptions { total: number; width?: number; complete?: string; incomplete?: string; renderThrottle?: number; clear?: boolean; showEta?: boolean; showPercent?: boolean; showCount?: boolean; } export class SmartlogProgressBar { private total: number; private current: number = 0; private width: number; private completeChar: string; private incomplete: string; private renderThrottle: number; private clear: boolean; private showEta: boolean; private showPercent: boolean; private showCount: boolean; private color: keyof typeof colors = 'green'; private startTime: number | null = null; private lastRenderTime: number = 0; private interactive: boolean; private lastLoggedPercent: number = 0; private logThreshold: number = 10; // Log every 10% in non-interactive mode constructor(options: IProgressBarOptions) { this.total = options.total; this.width = options.width || 30; this.completeChar = options.complete || '█'; this.incomplete = options.incomplete || '░'; this.renderThrottle = options.renderThrottle || 16; this.clear = options.clear !== undefined ? options.clear : false; this.showEta = options.showEta !== undefined ? options.showEta : true; this.showPercent = options.showPercent !== undefined ? options.showPercent : true; this.showCount = options.showCount !== undefined ? options.showCount : true; this.interactive = isInteractive(); } /** * Update the progress bar to a specific value */ public update(value: number): this { if (this.startTime === null) { this.startTime = Date.now(); } this.current = Math.min(value, this.total); if (!this.interactive) { // In non-interactive mode, log progress at certain thresholds const percent = Math.floor((this.current / this.total) * 100); const currentThreshold = Math.floor(percent / this.logThreshold) * this.logThreshold; if (currentThreshold > this.lastLoggedPercent || percent === 100) { this.lastLoggedPercent = currentThreshold; logMessage(`Progress: ${percent}% (${this.current}/${this.total})`); } return this; } // Throttle rendering to avoid excessive updates in interactive mode const now = Date.now(); if (now - this.lastRenderTime < this.renderThrottle) { return this; } this.lastRenderTime = now; this.render(); return this; } /** * Increment the progress bar by a value */ public increment(value: number = 1): this { return this.update(this.current + value); } /** * Mark the progress bar as complete */ public complete(): this { this.update(this.total); if (!this.interactive) { logMessage(`Completed: 100% (${this.total}/${this.total})`); return this; } if (this.clear) { process.stdout.write('\r\x1b[2K'); } else { process.stdout.write('\n'); } return this; } /** * Set the color of the progress bar */ public setColor(colorName: keyof typeof colors): this { if (colorName in colors) { this.color = colorName; } return this; } /** * Render the progress bar */ private render(): void { if (!this.interactive) { return; // Don't render in non-interactive mode } // Calculate percent complete const percent = Math.floor((this.current / this.total) * 100); const completeLength = Math.round((this.current / this.total) * this.width); const incompleteLength = this.width - completeLength; // Build the progress bar const completePart = colors[this.color] + this.completeChar.repeat(completeLength) + colors.reset; const incompletePart = this.incomplete.repeat(incompleteLength); const progressBar = `[${completePart}${incompletePart}]`; // Calculate ETA if needed let etaStr = ''; if (this.showEta && this.startTime !== null && this.current > 0) { const elapsed = (Date.now() - this.startTime) / 1000; const rate = this.current / elapsed; const remaining = Math.max(0, this.total - this.current); const eta = Math.round(remaining / rate); const mins = Math.floor(eta / 60); const secs = eta % 60; etaStr = ` eta: ${mins}m${secs}s`; } // Build additional information const percentStr = this.showPercent ? ` ${percent}%` : ''; const countStr = this.showCount ? ` ${this.current}/${this.total}` : ''; // Clear the line and render process.stdout.write('\r\x1b[2K'); process.stdout.write(`${progressBar}${percentStr}${countStr}${etaStr}`); } } // For backward compatibility with 'source-ora' module export class SmartlogSourceOra extends SmartlogSourceInteractive { // Add a stub for the oraInstance property for backward compatibility public get oraInstance() { // Use public methods instead of accessing private properties const instance = this; return { get text() { return ''; }, // We can't access private textContent directly start: () => instance.text(''), // This starts the spinner stop: () => instance.stop(), succeed: (text?: string) => instance.finishSuccess(text), fail: (text?: string) => instance.finishFail(text) }; } public set oraInstance(value: any) { // No-op, just for compatibility } }