emmercm/igir

View on GitHub
src/console/progressBarCli.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import { PassThrough } from 'node:stream';

import { E_CANCELED, Mutex } from 'async-mutex';
import cliProgress, { MultiBar } from 'cli-progress';
import wrapAnsi from 'wrap-ansi';

import ConsolePoly from '../polyfill/consolePoly.js';
import TimePoly from '../polyfill/timePoly.js';
import Timer from '../timer.js';
import Logger from './logger.js';
import LogLevel from './logLevel.js';
import ProgressBar, { ProgressBarSymbol } from './progressBar.js';
import ProgressBarPayload from './progressBarPayload.js';
import SingleBarFormatted from './singleBarFormatted.js';

/**
 * A {@link ProgressBar} that is intended to print to a TTY CLI.
 */
export default class ProgressBarCLI extends ProgressBar {
  private static readonly RENDER_MUTEX = new Mutex();

  private static readonly FPS = 4;

  private static multiBar?: MultiBar;

  private static progressBars: ProgressBarCLI[] = [];

  private static lastRedraw: number = 0;

  private static logQueue: string[] = [];

  private logger: Logger;

  private readonly payload: ProgressBarPayload;

  private readonly singleBarFormatted?: SingleBarFormatted;

  private waitingMessageTimeout?: Timer;

  private readonly waitingMessages: Map<string, number> = new Map();

  private constructor(
    logger: Logger,
    payload: ProgressBarPayload,
    singleBarFormatted?: SingleBarFormatted,
  ) {
    super();
    this.logger = logger;
    this.payload = payload;
    this.singleBarFormatted = singleBarFormatted;
    if (singleBarFormatted) {
      ProgressBarCLI.progressBars = [...ProgressBarCLI.progressBars, this];
    }
  }

  /**
   * Create a new {@link ProgressBarCLI}, and initialize the {@link MultiBar} if it hasn't been yet.
   */
  static new(logger: Logger, name: string, symbol: string, initialTotal = 0): ProgressBarCLI {
    if (!ProgressBarCLI.multiBar) {
      ProgressBarCLI.multiBar = new cliProgress.MultiBar(
        {
          stream: logger.getLogLevel() < LogLevel.NEVER ? logger.getStream() : new PassThrough(),
          barsize: 20,
          fps: 1 / 60, // limit the automatic redraws
          forceRedraw: true,
          emptyOnZero: true,
          hideCursor: true,
        },
        cliProgress.Presets.shades_grey,
      );
      process.on('exit', () => {
        this.multiBar?.stop();
      });
    }

    const initialPayload: ProgressBarPayload = {
      symbol,
      name,
      inProgress: 0,
    };

    if (!logger.isTTY()) {
      // Only create progress bars for TTY consoles
      return new ProgressBarCLI(logger, initialPayload);
    }

    const singleBarFormatted = new SingleBarFormatted(
      ProgressBarCLI.multiBar,
      initialTotal,
      initialPayload,
    );
    const progressBarCLI = new ProgressBarCLI(logger, initialPayload, singleBarFormatted);
    progressBarCLI.render(true);
    return progressBarCLI;
  }

  /**
   * Stop the {@link MultiBar} (and therefore everyProgressBar).
   */
  static stop(): void {
    // Freeze (and delete) any lingering progress bars
    const progressBarsCopy = ProgressBarCLI.progressBars.slice();
    progressBarsCopy.forEach((progressBar) => progressBar.freeze());

    // Clear the last deleted, non-frozen progress bar
    ProgressBarCLI.multiBar?.log(' ');
    this.multiBar?.update();

    this.multiBar?.stop();
    this.multiBar = undefined;
    // Forcing a render shouldn't be necessary
  }

  /**
   * Applications that are too synchronous or have a high concurrency (e.g. with async.js, p-limit,
   * p-map, etc.) keep cli-progress from redrawing with its setTimeout(), so it might be necessary
   * to force it. This function needs to be safe to be called concurrently because of the way
   * cli-progress clears previous output.
   * @see https://github.com/npkgz/cli-progress/issues/79
   */
  render(force = false): void {
    this.singleBarFormatted?.getSingleBar().update(this.payload);

    const callback = (): void => {
      // Dequeue all log messages
      if (ProgressBarCLI.multiBar && ProgressBarCLI.logQueue.length > 0) {
        const consoleWidth = ConsolePoly.consoleWidth();
        const logMessage = ProgressBarCLI.logQueue
          // Wrapping is broken: https://github.com/npkgz/cli-progress/issues/142
          .map((msg) =>
            wrapAnsi(msg, consoleWidth, { trim: false })
              // ...and if we manually wrap lines, we also need to deal with overwriting existing
              //  progress bar output.
              .split('\n')
              // TODO(cemmer): this appears to only overwrite the last line, not any others?
              .join(`\n${this.logger.isTTY() ? '\x1b[K' : ''}`),
          )
          .join('\n');
        ProgressBarCLI.multiBar.log(`${logMessage}\n`);
        ProgressBarCLI.logQueue = [];
      }

      ProgressBarCLI.multiBar?.update();
      ProgressBarCLI.lastRedraw = TimePoly.hrtimeMillis();
      ProgressBarCLI.RENDER_MUTEX.cancel(); // cancel all waiting locks, we just redrew
    };

    if (force) {
      callback();
      return;
    }

    // Limit the frequency of redrawing
    const elapsedMs = TimePoly.hrtimeMillis(ProgressBarCLI.lastRedraw);
    if (elapsedMs < 1000 / ProgressBarCLI.FPS) {
      return;
    }

    setImmediate(async () => {
      try {
        await ProgressBarCLI.RENDER_MUTEX.runExclusive(callback);
      } catch (error) {
        if (error !== E_CANCELED) {
          throw error;
        }
      }
    });
  }

  /**
   * Reset the {@link ProgressBar}'s progress to zero and change its total.
   */
  reset(total: number): void {
    this.singleBarFormatted?.getSingleBar().setTotal(total);
    this.singleBarFormatted?.getSingleBar().update(0);
    this.payload.inProgress = 0;
    this.payload.waitingMessage = undefined;
    this.render(true);
  }

  private logPayload(): void {
    const name = this.payload.name ?? '';
    const finishedMessageWrapped = this.payload.finishedMessage
      ?.split('\n')
      .map((line, idx) => {
        if (idx === 0) {
          return line;
        }
        return `   ${line}`;
      })
      .join('\n');

    this.log(
      LogLevel.ALWAYS,
      `${name}${finishedMessageWrapped ? ` ... ${finishedMessageWrapped}` : ''}`,
    );
    this.render(true);
  }

  setName(name: string): void {
    if (this.payload.name === name) {
      return;
    }
    this.payload.name = name;
    this.render(true);
  }

  setSymbol(symbol: string): void {
    if (this.payload.symbol === symbol) {
      return;
    }
    this.payload.symbol = symbol;
    this.render(true);
  }

  /**
   * If progress hasn't been made by some timeout period, then show a waiting message to let the
   * user know that there is still something processing.
   */
  addWaitingMessage(waitingMessage: string): void {
    if (!this.singleBarFormatted) {
      return;
    }
    this.waitingMessages.set(waitingMessage, TimePoly.hrtimeMillis());

    if (!this.waitingMessageTimeout) {
      this.waitingMessageTimeout = Timer.setInterval(() => {
        const currentMillis = TimePoly.hrtimeMillis();
        const newWaitingMessagePair = [...this.waitingMessages].find(
          ([, ms]) => currentMillis - ms >= 5000,
        );

        const newWaitingMessage =
          newWaitingMessagePair !== undefined ? newWaitingMessagePair[0] : undefined;

        if (newWaitingMessage !== this.payload.waitingMessage) {
          this.payload.waitingMessage = newWaitingMessage;
          this.render(true);
        }
      }, 1000 / ProgressBarCLI.FPS);
    }
  }

  /**
   * Remove a waiting message to let the user know some processing has finished.
   */
  removeWaitingMessage(waitingMessage: string): void {
    if (!this.singleBarFormatted) {
      return;
    }
    this.waitingMessages.delete(waitingMessage);
  }

  /**
   * Increment the total by some amount.
   */
  incrementTotal(increment: number): void {
    if (!this.singleBarFormatted) {
      return;
    }

    this.singleBarFormatted
      .getSingleBar()
      .setTotal(this.singleBarFormatted.getSingleBar().getTotal() + increment);
    this.render();
  }

  /**
   * Increment the in-progress count by one.
   */
  incrementProgress(): void {
    this.payload.inProgress = Math.max(this.payload.inProgress ?? 0, 0) + 1;
    this.render();
  }

  /**
   * Decrement the in-progress count by one, and increment the completed count by one.
   */
  incrementDone(): void {
    this.payload.inProgress = Math.max((this.payload.inProgress ?? 0) - 1, 0);
    this.singleBarFormatted?.getSingleBar().increment();
    this.render();
  }

  /**
   * Set the completed count.
   */
  update(current: number): void {
    this.singleBarFormatted?.getSingleBar().update(current);
    this.render();
  }

  /**
   * Set the completed count to the total, and render any completion message.
   */
  done(finishedMessage?: string): void {
    this.setSymbol(ProgressBarSymbol.DONE);

    const total = this.singleBarFormatted?.getSingleBar().getTotal() ?? 0;
    if (total > 0) {
      this.singleBarFormatted?.getSingleBar().update(total);
    } else {
      this.singleBarFormatted?.getSingleBar().update(total + 1);
    }

    this.payload.waitingMessage = undefined;
    if (finishedMessage) {
      this.payload.finishedMessage = finishedMessage;
    }

    this.render(true);
  }

  /**
   * Return a copy of this {@link ProgressBar} with a new string prefix.
   */
  setLoggerPrefix(prefix: string): void {
    this.logger = this.logger.withLoggerPrefix(prefix);
  }

  /**
   * Log a message at some specified {@link LogLevel}.
   */
  log(logLevel: LogLevel, message: string): void {
    ProgressBarCLI.log(this.logger, logLevel, message);
  }

  /**
   * Log a message at some specified {@link LogLevel}.
   */
  static log(logger: Logger, logLevel: LogLevel, message: string): void {
    if (logger.getLogLevel() > logLevel) {
      return;
    }

    const formattedMessage = logger.formatMessage(logLevel, message);
    ProgressBarCLI.logQueue.push(formattedMessage);
  }

  /**
   * When the number of progress bars exceeds the height of the console, cli-progress fails to be
   * able to clear them all reliably. It's recommended you don't have too many active progress bars
   * at once.
   * @see https://github.com/npkgz/cli-progress/issues/59
   */
  freeze(): void {
    if (!this.singleBarFormatted) {
      this.logPayload();
      return;
    }

    this.render(true);
    ProgressBarCLI.multiBar?.log(`${this.singleBarFormatted?.getLastOutput()}\n`);
    this.delete();
  }

  /**
   * Delete this {@link ProgressBarCLI} from the CLI.
   */
  delete(): void {
    this.waitingMessageTimeout?.cancel();

    if (!this.singleBarFormatted) {
      return;
    }

    ProgressBarCLI.multiBar?.remove(this.singleBarFormatted.getSingleBar());
    ProgressBarCLI.progressBars = ProgressBarCLI.progressBars.filter(
      (singleBar) => singleBar.singleBarFormatted !== this.singleBarFormatted,
    );
  }
}