417 lines
11 KiB
TypeScript

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
}
}