import cliTruncate from 'cli-truncate' import logUpdate from 'log-update' import { EOL } from 'os' import cliWrap from 'wrap-ansi' import type { ListrContext } from '@interfaces/listr.interface' import type { ListrRenderer } from '@interfaces/renderer.interface' import type { Task } from '@lib/task' import colorette from '@utils/colorette' import { figures } from '@utils/figures' import { indentString } from '@utils/indent-string' import { isUnicodeSupported } from '@utils/is-unicode-supported' import { parseTaskTime } from '@utils/parse-time' /** Default updating renderer for Listr2 */ export class DefaultRenderer implements ListrRenderer { /** designates whether this renderer can output to a non-tty console */ public static nonTTY = false /** renderer options for the defauult renderer */ public static rendererOptions: { /** * indentation per level of subtask * * @default 2 */ indentation?: number /** * clear all the output generated by the renderer when the task finishes its execution * * @default false * @global global option that can not be temperated with subtasks */ clearOutput?: boolean /** * show the subtasks of the current task * * @default true */ showSubtasks?: boolean /** * collapse subtasks after current task completes its execution * * @default true */ collapse?: boolean /** * show skip messages or show the original title of the task, this will also disable collapseSkips mode * * You can disable showing the skip messages, even though you passed in a message by settings this option, * if you want to keep the original task title intact. * * @default true */ showSkipMessage?: boolean /** * collapse skip messages into a single message and overwrite the task title * * @default true */ collapseSkips?: boolean /** * suffix skip messages with [SKIPPED] when in collapseSkips mode * * @default true */ suffixSkips?: boolean /** * shows the thrown error message or show the original title of the task, this will also disable collapseErrors mode * You can disable showing the error messages, even though you passed in a message by settings this option, * if you want to keep the original task title intact. * * @default true */ showErrorMessage?: boolean /** * collapse error messages into a single message and overwrite the task title * * @default true */ collapseErrors?: boolean /** * suffix retry messages with [RETRY-${COUNT}] when retry is enabled for a task * * @default true */ suffixRetries?: boolean /** * only update through triggers from renderhook * * useful for tests and stuff. this will disable showing spinner and only update the screen if something else has * happened in the task worthy to show * * @default false * @global global option that can not be temperated with subtasks */ lazy?: boolean /** * show duration for all tasks * * @default false * @global global option that can not be temperated with subtasks */ showTimer?: boolean /** * removes empty lines from the data output * * @default true */ removeEmptyLines?: boolean /** * formats data output depending on your requirements. * * @default 'truncate' * @global global option that can not be temperated with subtasks */ formatOutput?: 'truncate' | 'wrap' } = { indentation: 2, clearOutput: false, showSubtasks: true, collapse: true, collapseSkips: true, showSkipMessage: true, suffixSkips: true, collapseErrors: true, showErrorMessage: true, suffixRetries: true, lazy: false, showTimer: false, removeEmptyLines: true, formatOutput: 'truncate' } /** per task options for the default renderer */ public static rendererTaskOptions: { /** * write task output to the bottom bar instead of the gap under the task title itself. * useful for a stream of data. * @default false * * `true` only keep 1 line of the latest data outputted by the task. * `false` only keep 1 line of the latest data outputted by the task. * `number` will keep designated data of the latest data outputted by the task. */ bottomBar?: boolean | number /** * keep output after task finishes * @default false * * works both for the bottom bar and the default behavior */ persistentOutput?: boolean /** * show the task time if it was successful */ showTimer?: boolean } private id?: NodeJS.Timeout private bottomBar: Record<string, { data?: string[], items?: number }> = {} private promptBar: string private readonly spinner: string[] = !isUnicodeSupported() ? [ '-', '\\', '|', '/' ] : [ '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' ] private spinnerPosition = 0 constructor (public tasks: Task<any, typeof DefaultRenderer>[], public options: typeof DefaultRenderer['rendererOptions'], public renderHook$?: Task<any, any>['renderHook$']) { this.options = { ...DefaultRenderer.rendererOptions, ...this.options } } public getTaskOptions (task: Task<any, typeof DefaultRenderer>): typeof DefaultRenderer['rendererTaskOptions'] { return { ...DefaultRenderer.rendererTaskOptions, ...task.rendererTaskOptions } } public isBottomBar (task: Task<any, typeof DefaultRenderer>): boolean { const bottomBar = this.getTaskOptions(task).bottomBar return typeof bottomBar === 'number' && bottomBar !== 0 || typeof bottomBar === 'boolean' && bottomBar !== false } public hasPersistentOutput (task: Task<any, typeof DefaultRenderer>): boolean { return this.getTaskOptions(task).persistentOutput === true } public hasTimer (task: Task<any, typeof DefaultRenderer>): boolean { return this.getTaskOptions(task).showTimer === true } public getSelfOrParentOption<T extends keyof typeof DefaultRenderer['rendererOptions']>( task: Task<any, typeof DefaultRenderer>, key: T ): typeof DefaultRenderer['rendererOptions'][T] { return task?.rendererOptions?.[key] ?? this.options?.[key] } /* istanbul ignore next */ public getTaskTime (task: Task<any, typeof DefaultRenderer>): string { return colorette.dim(`[${parseTaskTime(task.message.duration)}]`) } public createRender (options?: { tasks?: boolean, bottomBar?: boolean, prompt?: boolean }): string { options = { ...{ tasks: true, bottomBar: true, prompt: true }, ...options } const render: string[] = [] const renderTasks = this.multiLineRenderer(this.tasks) const renderBottomBar = this.renderBottomBar() const renderPrompt = this.renderPrompt() if (options.tasks && renderTasks?.trim().length > 0) { render.push(renderTasks) } if (options.bottomBar && renderBottomBar?.trim().length > 0) { render.push((render.length > 0 ? EOL : '') + renderBottomBar) } if (options.prompt && renderPrompt?.trim().length > 0) { render.push((render.length > 0 ? EOL : '') + renderPrompt) } return render.length > 0 ? render.join(EOL) : '' } public render (): void { // Do not render if we are already rendering if (this.id) { return } const updateRender = (): void => logUpdate(this.createRender()) /* istanbul ignore if */ if (!this.options?.lazy) { this.id = setInterval(() => { this.spinnerPosition = ++this.spinnerPosition % this.spinner.length updateRender() }, 100) } this.renderHook$.subscribe(() => { updateRender() }) } public end (): void { clearInterval(this.id) if (this.id) { this.id = undefined } // clear log updater logUpdate.clear() logUpdate.done() // directly write to process.stdout, since logupdate only can update the seen height of terminal if (!this.options.clearOutput) { process.stdout.write(this.createRender({ prompt: false }) + EOL) } } // eslint-disable-next-line private multiLineRenderer(tasks: Task<any, typeof DefaultRenderer>[], level = 0): string { let output: string[] = [] for (const task of tasks) { if (task.isEnabled()) { // Current Task Title if (task.hasTitle()) { if (!(tasks.some((task) => task.hasFailed()) && !task.hasFailed() && task.options.exitOnError !== false && !(task.isCompleted() || task.isSkipped()))) { // if task is skipped if (task.hasFailed() && this.getSelfOrParentOption(task, 'collapseErrors')) { // current task title and skip change the title output = [ ...output, this.formatString( !task.hasSubtasks() && task.message.error && this.getSelfOrParentOption(task, 'showErrorMessage') ? task.message.error : task.title, this.getSymbol(task), level ) ] } else if (task.isSkipped() && this.getSelfOrParentOption(task, 'collapseSkips')) { // current task title and skip change the title output = [ ...output, this.formatString( this.addSuffixToMessage( task.message.skip && this.getSelfOrParentOption(task, 'showSkipMessage') ? task.message.skip : task.title, 'SKIPPED', this.getSelfOrParentOption(task, 'suffixSkips') ), this.getSymbol(task), level ) ] } else if (task.isRetrying() && this.getSelfOrParentOption(task, 'suffixRetries')) { output = [ ...output, this.formatString(this.addSuffixToMessage(task.title, `RETRYING-${task.message.retry.count}`), this.getSymbol(task), level) ] } else if (task.isCompleted() && task.hasTitle() && (this.getSelfOrParentOption(task, 'showTimer') || this.hasTimer(task))) { // task with timer output = [ ...output, this.formatString(`${task?.title} ${this.getTaskTime(task)}`, this.getSymbol(task), level) ] } else { // normal state output = [ ...output, this.formatString(task.title, this.getSymbol(task), level) ] } } else { // some sibling task but self has failed and this has stopped output = [ ...output, this.formatString(task.title, colorette.red(figures.squareSmallFilled), level) ] } } // task should not have subtasks since subtasks will handle the error already // maybe it is a better idea to show the error or skip messages when show subtasks is disabled. if (!task.hasSubtasks() || !this.getSelfOrParentOption(task, 'showSubtasks')) { // without the collapse option for skip and errors if ( task.hasFailed() && this.getSelfOrParentOption(task, 'collapseErrors') === false && (this.getSelfOrParentOption(task, 'showErrorMessage') || !this.getSelfOrParentOption(task, 'showSubtasks')) ) { // show skip data if collapsing is not defined output = [ ...output, this.dumpData(task, level, 'error') ] } else if ( task.isSkipped() && this.getSelfOrParentOption(task, 'collapseSkips') === false && (this.getSelfOrParentOption(task, 'showSkipMessage') || !this.getSelfOrParentOption(task, 'showSubtasks')) ) { // show skip data if collapsing is not defined output = [ ...output, this.dumpData(task, level, 'skip') ] } } // Current Task Output if (task?.output) { if ((task.isPending() || task.isRetrying() || task.isRollingBack()) && task.isPrompt()) { // data output to prompt bar if prompt this.promptBar = task.output } else if (this.isBottomBar(task) || !task.hasTitle()) { // data output to bottom bar const data = [ this.dumpData(task, -1) ] // create new if there is no persistent storage created for bottom bar if (!this.bottomBar[task.id]) { this.bottomBar[task.id] = {} this.bottomBar[task.id].data = [] const bottomBar = this.getTaskOptions(task).bottomBar if (typeof bottomBar === 'boolean') { this.bottomBar[task.id].items = 1 } else { this.bottomBar[task.id].items = bottomBar } } // persistent bottom bar and limit items in it if (!this.bottomBar[task.id]?.data?.some((element) => data.includes(element)) && !task.isSkipped()) { this.bottomBar[task.id].data = [ ...this.bottomBar[task.id].data, ...data ] } } else if (task.isPending() || task.isRetrying() || task.isRollingBack() || this.hasPersistentOutput(task)) { // keep output if persistent output is set output = [ ...output, this.dumpData(task, level) ] } } // render subtasks, some complicated conditionals going on if ( // check if renderer option is on first this.getSelfOrParentOption(task, 'showSubtasks') !== false && // if it doesnt have subtasks no need to check task.hasSubtasks() && (task.isPending() || task.hasFailed() || task.isCompleted() && !task.hasTitle() || // have to be completed and have subtasks task.isCompleted() && this.getSelfOrParentOption(task, 'collapse') === false && !task.subtasks.some((subtask) => subtask.rendererOptions.collapse === true) || // if any of the subtasks have the collapse option of task.subtasks.some((subtask) => subtask.rendererOptions.collapse === false) || // if any of the subtasks has failed task.subtasks.some((subtask) => subtask.hasFailed()) || // if any of the subtasks rolled back task.subtasks.some((subtask) => subtask.hasRolledBack())) ) { // set level const subtaskLevel = !task.hasTitle() ? level : level + 1 // render the subtasks as in the same way const subtaskRender = this.multiLineRenderer(task.subtasks, subtaskLevel) if (subtaskRender?.trim() !== '' && !task.subtasks.every((subtask) => !subtask.hasTitle())) { output = [ ...output, subtaskRender ] } } // after task is finished actions if (task.isCompleted() || task.hasFailed() || task.isSkipped() || task.hasRolledBack()) { // clean up prompts this.promptBar = null // clean up bottom bar items if not indicated otherwise if (!this.hasPersistentOutput(task)) { delete this.bottomBar[task.id] } } } } output = output.filter(Boolean) if (output.length > 0) { return output.join(EOL) } else { return } } private renderBottomBar (): string { // parse through all objects return only the last mentioned items if (Object.keys(this.bottomBar).length > 0) { this.bottomBar = Object.keys(this.bottomBar).reduce<Record<PropertyKey, { data?: string[], items?: number }>>((o, key) => { if (!o?.[key]) { o[key] = {} } o[key] = this.bottomBar[key] this.bottomBar[key].data = this.bottomBar[key].data.slice(-this.bottomBar[key].items) o[key].data = this.bottomBar[key].data return o }, {}) return Object.values(this.bottomBar) .reduce((o, value) => o = [ ...o, ...value.data ], []) .filter(Boolean) .join(EOL) } } private renderPrompt (): string { if (this.promptBar) { return this.promptBar } } private dumpData (task: Task<ListrContext, typeof DefaultRenderer>, level: number, source: 'output' | 'skip' | 'error' = 'output'): string { let data: string | boolean switch (source) { case 'output': data = task.output break case 'skip': data = task.message.skip break case 'error': data = task.message.error break } // dont return anything on some occasions if (task.hasTitle() && source === 'error' && data === task.title) { return } if (typeof data === 'string') { return this.formatString(data, this.getSymbol(task, true), level + 1) } } private formatString (str: string, icon: string, level: number): string { // we dont like empty data around here if (str.trim() === '') { return } str = `${icon} ${str}` let parsedStr: string[] let columns = process.stdout.columns ? process.stdout.columns : 80 columns = columns - level * this.options.indentation - 2 switch (this.options.formatOutput) { case 'truncate': parsedStr = str.split(EOL).map((s, i) => { return cliTruncate(this.indentMultilineOutput(s, i), columns) }) break case 'wrap': parsedStr = cliWrap(str, columns, { hard: true }) .split(EOL) .map((s, i) => this.indentMultilineOutput(s, i)) break default: throw new Error('Format option for the renderer is wrong.') } // this removes the empty lines if (this.options.removeEmptyLines) { parsedStr = parsedStr.filter(Boolean) } return indentString(parsedStr.join(EOL), level * this.options.indentation) } private indentMultilineOutput (str: string, i: number): string { return i > 0 ? indentString(str.trim(), 2) : str.trim() } // eslint-disable-next-line complexity private getSymbol (task: Task<ListrContext, typeof DefaultRenderer>, data = false): string { if (task.isPending() && !data) { return this.options?.lazy || this.getSelfOrParentOption(task, 'showSubtasks') !== false && task.hasSubtasks() && !task.subtasks.every((subtask) => !subtask.hasTitle()) ? colorette.yellow(figures.pointer) : colorette.yellowBright(this.spinner[this.spinnerPosition]) } else if (task.isCompleted() && !data) { return task.hasSubtasks() && task.subtasks.some((subtask) => subtask.hasFailed()) ? colorette.yellow(figures.warning) : colorette.green(figures.tick) } else if (task.isRetrying() && !data) { return this.options?.lazy ? colorette.yellow(figures.warning) : colorette.yellow(this.spinner[this.spinnerPosition]) } else if (task.isRollingBack() && !data) { return this.options?.lazy ? colorette.red(figures.warning) : colorette.red(this.spinner[this.spinnerPosition]) } else if (task.hasRolledBack() && !data) { return colorette.red(figures.arrowLeft) } else if (task.hasFailed() && !data) { return task.hasSubtasks() ? colorette.red(figures.pointer) : colorette.red(figures.cross) } else if (task.isSkipped() && !data && this.getSelfOrParentOption(task, 'collapseSkips') === false) { return colorette.yellow(figures.warning) } else if (task.isSkipped() && (data || this.getSelfOrParentOption(task, 'collapseSkips'))) { return colorette.yellow(figures.arrowDown) } return !data ? colorette.dim(figures.squareSmallFilled) : figures.pointerSmall } private addSuffixToMessage (message: string, suffix: string, condition?: boolean): string { return condition ?? true ? message + colorette.dim(` [${suffix}]`) : message } }