415 lines
11 KiB
TypeScript
415 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 complete: 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.complete = 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.complete.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() {
|
|
return {
|
|
text: this.textContent,
|
|
start: () => this.start(),
|
|
stop: () => this.stop(),
|
|
succeed: (text?: string) => this.finishSuccess(text),
|
|
fail: (text?: string) => this.finishFail(text)
|
|
};
|
|
}
|
|
|
|
public set oraInstance(value: any) {
|
|
// No-op, just for compatibility
|
|
}
|
|
} |