diff --git a/packages/prompts/src/task-log.ts b/packages/prompts/src/task-log.ts index a484499..50239a8 100644 --- a/packages/prompts/src/task-log.ts +++ b/packages/prompts/src/task-log.ts @@ -1,15 +1,7 @@ import type { Writable } from 'node:stream'; -import { getColumns } from '@clack/core'; +import { Component, RenderHost } from '@clack/core'; import color from 'picocolors'; -import { erase } from 'sisteransi'; -import { - type CommonOptions, - isCI as isCIFn, - isTTY as isTTYFn, - S_BAR, - S_STEP_SUBMIT, -} from './common.js'; -import { log } from './log.js'; +import { type CommonOptions, S_BAR, S_ERROR, S_STEP_SUBMIT, S_SUCCESS } from './common.js'; export interface TaskLogOptions extends CommonOptions { title: string; @@ -36,106 +28,153 @@ interface BufferEntry { }; } -/** - * Renders a log which clears on success and remains on failure - */ -export const taskLog = (opts: TaskLogOptions) => { - const output: Writable = opts.output ?? process.stdout; - const columns = getColumns(output); - const secondarySymbol = color.gray(S_BAR); - const spacing = opts.spacing ?? 1; - const barSize = 3; - const retainLog = opts.retainLog === true; - const isTTY = !isCIFn() && isTTYFn(output); - - output.write(`${secondarySymbol}\n`); - output.write(`${color.green(S_STEP_SUBMIT)} ${opts.title}\n`); - for (let i = 0; i < spacing; i++) { - output.write(`${secondarySymbol}\n`); - } - - const buffers: BufferEntry[] = [ +class TaskLogComponent extends Component { + #options: TaskLogOptions; + #buffers: BufferEntry[] = [ { value: '', full: '', }, ]; - let lastMessageWasRaw = false; + #lastMessageWasRaw = new WeakMap(); + #completedMessage?: { + status: 'success' | 'error'; + message: string; + opts?: TaskLogCompletionOptions; + }; - const clear = (clearTitle: boolean): void => { - if (buffers.length === 0) { - return; - } + constructor(opts: TaskLogOptions) { + super(undefined); + this.#options = opts; + } - let lines = 0; + #renderBufferResult(buffer: BufferEntry): string[] { + if (!buffer.result) { + return []; + } - if (clearTitle) { - lines += spacing + 2; + const secondarySymbol = color.gray(S_BAR); + const successSymbol = color.green(S_SUCCESS); + const errorSymbol = color.red(S_ERROR); + const retainLog = this.#options.retainLog === true; + const bufferLines: string[] = []; + + if (buffer.result.status === 'error') { + bufferLines.push( + `${errorSymbol} ${buffer.result.message.split('\n').join(`\n${secondarySymbol}`)}` + ); + } else { + if (retainLog || buffer.value === '') { + bufferLines.push( + `${successSymbol} ${buffer.result.message.split('\n').join(`\n${secondarySymbol}`)}` + ); + } } - for (const buffer of buffers) { - const { value, result } = buffer; - let text = result?.message ?? value; + return bufferLines; + } - if (text.length === 0) { - continue; - } + #renderBuffer(buffer: BufferEntry, full?: boolean): string[] { + const secondarySymbol = color.gray(S_BAR); + const bufferLines: string[] = []; - if (result === undefined && buffer.header !== undefined && buffer.header !== '') { - text += `\n${buffer.header}`; - } + if (buffer.header !== undefined && buffer.header !== '') { + bufferLines.push( + `${secondarySymbol} ${buffer.header + .split('\n') + .map((line) => color.bold(line)) + .join(`\n${secondarySymbol}`)}` + ); + } + const messages = full && buffer.full ? `${buffer.full}\n${buffer.value}` : buffer.value; + if (messages !== '') { + bufferLines.push( + `${secondarySymbol} ${messages + .split('\n') + .map((line) => color.dim(line)) + .join(`\n${secondarySymbol} `)}` + ); + } + return bufferLines; + } - const bufferHeight = text.split('\n').reduce((count, line) => { - if (line === '') { - return count + 1; - } - return count + Math.ceil((line.length + barSize) / columns); - }, 0); + render(): string { + const secondarySymbol = color.gray(S_BAR); + const successSymbol = color.green(S_SUCCESS); + const errorSymbol = color.red(S_ERROR); + const spacing = this.#options.spacing ?? 1; + const bufferLines: string[] = []; - lines += bufferHeight; + for (let i = 0; i < spacing; i++) { + bufferLines.push(secondarySymbol); } - if (lines > 0) { - lines += 1; - output.write(erase.lines(lines)); + if (this.#completedMessage) { + bufferLines.push( + `${this.#completedMessage.status === 'success' ? successSymbol : errorSymbol} ${this.#completedMessage.message + .split('\n') + .join(`\n${secondarySymbol} `)}` + ); + if (this.#completedMessage.opts?.showLog) { + for (const buffer of this.#buffers) { + const contentLines = this.#renderBuffer(buffer, true); + for (const line of contentLines) { + bufferLines.push(line); + } + } + } + } else { + for (const buffer of this.#buffers) { + if (buffer.result) { + const resultLines = this.#renderBufferResult(buffer); + for (const line of resultLines) { + bufferLines.push(line); + } + } else { + const contentLines = this.#renderBuffer(buffer); + for (const line of contentLines) { + bufferLines.push(line); + } + } + } } - }; - const printBuffer = (buffer: BufferEntry, messageSpacing?: number, full?: boolean): void => { - const messages = full ? `${buffer.full}\n${buffer.value}` : buffer.value; - if (buffer.header !== undefined && buffer.header !== '') { - log.message(buffer.header.split('\n').map(color.bold), { - output, - secondarySymbol, - symbol: secondarySymbol, - spacing: 0, - }); + + return ` +${secondarySymbol} +${color.green(S_STEP_SUBMIT)} ${this.#options.title} +${bufferLines.join('\n')} + `.trim(); + } + + closeGroup(name: string, status: 'success' | 'error', message: string): void { + const buffer = this.#buffers.find((b) => b.header === name); + if (!buffer) { + return; } - log.message(messages.split('\n').map(color.dim), { - output, - secondarySymbol, - symbol: secondarySymbol, - spacing: messageSpacing ?? spacing, - }); - }; - const renderBuffer = (): void => { - for (const buffer of buffers) { - const { header, value, full } = buffer; - if ((header === undefined || header.length === 0) && value.length === 0) { - continue; - } - printBuffer(buffer, undefined, retainLog === true && full.length > 0); + buffer.result = { + status, + message, + }; + this.requestUpdate(); + } + + message(msg: string, opts?: TaskLogMessageOptions, bufferName?: string): void { + const retainLog = this.#options.retainLog === true; + const buffer = bufferName + ? this.#buffers.find((b) => b.header === bufferName) + : this.#buffers[0]; + if (!buffer) { + return; } - }; - const message = (buffer: BufferEntry, msg: string, mopts?: TaskLogMessageOptions) => { - clear(false); - if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer.value !== '') { + const lastMessageWasRaw = this.#lastMessageWasRaw.get(buffer) ?? false; + if ((opts?.raw !== true || !lastMessageWasRaw) && buffer.value !== '') { buffer.value += '\n'; } buffer.value += msg; - lastMessageWasRaw = mopts?.raw === true; - if (opts.limit !== undefined) { + this.#lastMessageWasRaw.set(buffer, opts?.raw === true); + if (this.#options.limit !== undefined) { const lines = buffer.value.split('\n'); - const linesToRemove = lines.length - opts.limit; + const linesToRemove = lines.length - this.#options.limit; if (linesToRemove > 0) { const removedLines = lines.splice(0, linesToRemove); if (retainLog) { @@ -144,83 +183,70 @@ export const taskLog = (opts: TaskLogOptions) => { } buffer.value = lines.join('\n'); } - if (isTTY) { - printBuffers(); - } - }; - const printBuffers = (): void => { - for (const buffer of buffers) { - if (buffer.result) { - if (buffer.result.status === 'error') { - log.error(buffer.result.message, { output, secondarySymbol, spacing: 0 }); - } else { - log.success(buffer.result.message, { output, secondarySymbol, spacing: 0 }); - } - } else if (buffer.value !== '') { - printBuffer(buffer, 0); - } - } - }; - const completeBuffer = (buffer: BufferEntry, result: BufferEntry['result']): void => { - clear(false); + this.requestUpdate(); + } - buffer.result = result; + group(name: string): void { + const buffer: BufferEntry = { + header: name, + value: '', + full: '', + }; + this.#buffers.push(buffer); + } - if (isTTY) { - printBuffers(); - } - }; + error(message: string, opts?: TaskLogCompletionOptions): void { + this.#completedMessage = { + status: 'error', + message, + opts, + }; + this.requestUpdate(); + } + + success(message: string, opts?: TaskLogCompletionOptions): void { + this.#completedMessage = { + status: 'success', + message, + opts, + }; + this.requestUpdate(); + } +} + +/** + * Renders a log which clears on success and remains on failure + */ +export const taskLog = (opts: TaskLogOptions) => { + const output: Writable = opts.output ?? process.stdout; + const host = new RenderHost(output); + const component = new TaskLogComponent(opts); + host.attach(component); + host.requestUpdate(); return { message(msg: string, mopts?: TaskLogMessageOptions) { - message(buffers[0], msg, mopts); + component.message(msg, mopts); }, group(name: string) { - const buffer: BufferEntry = { - header: name, - value: '', - full: '', - }; - buffers.push(buffer); + component.group(name); return { message(msg: string, mopts?: TaskLogMessageOptions) { - message(buffer, msg, mopts); + component.message(msg, mopts, name); }, error(message: string) { - completeBuffer(buffer, { - status: 'error', - message, - }); + component.closeGroup(name, 'error', message); }, success(message: string) { - completeBuffer(buffer, { - status: 'success', - message, - }); + component.closeGroup(name, 'success', message); }, }; }, error(message: string, opts?: TaskLogCompletionOptions): void { - clear(true); - log.error(message, { output, secondarySymbol, spacing: 1 }); - if (opts?.showLog !== false) { - renderBuffer(); - } - // clear buffer since error is an end state - buffers.splice(1, buffers.length - 1); - buffers[0].value = ''; - buffers[0].full = ''; + component.error(message, opts); }, success(message: string, opts?: TaskLogCompletionOptions): void { - clear(true); - log.success(message, { output, secondarySymbol, spacing: 1 }); - if (opts?.showLog === true) { - renderBuffer(); - } - // clear buffer since success is an end state - buffers.splice(1, buffers.length - 1); - buffers[0].value = ''; - buffers[0].full = ''; + component.success(message, opts); }, }; };