UNPKG

@oclif/multi-stage-output

Version:

Terminal output for oclif commands with multiple stages

342 lines (341 loc) 18 kB
import { getLogger } from '@oclif/core/logger'; import { Box, Text, useStdout } from 'ink'; import React from 'react'; import wrapAnsi from 'wrap-ansi'; import { constructDesignParams } from '../design.js'; import { Divider } from './divider.js'; import { Icon } from './icon.js'; import { SpinnerOrError, SpinnerOrErrorOrChildren } from './spinner.js'; import { Timer } from './timer.js'; function StaticKeyValue({ color, isBold, label, value }) { if (!value) return false; return (React.createElement(Box, { key: label, flexWrap: "wrap" }, React.createElement(Text, { bold: isBold }, label, ": "), React.createElement(Text, { color: color }, value))); } function SimpleMessage({ color, isBold, value }) { if (!value) return false; return (React.createElement(Text, { bold: isBold, color: color }, value)); } function StageInfos({ design, error, keyValuePairs, stage, }) { return keyValuePairs .filter((kv) => kv.stage === stage) .map((kv) => { const key = `${kv.label}-${kv.value}`; if (kv.type === 'message') { return (React.createElement(Box, { key: key, flexDirection: "row" }, React.createElement(Icon, { icon: design.icons.info }), React.createElement(SimpleMessage, { ...kv }))); } if (kv.type === 'dynamic-key-value') { return (React.createElement(Box, { key: key, flexWrap: "wrap" }, React.createElement(Icon, { icon: design.icons.info }), React.createElement(SpinnerOrErrorOrChildren, { error: error, label: `${kv.label}:`, labelPosition: "left", type: design.spinners.info, design: design }, kv.value ? (React.createElement(Text, { bold: kv.isBold, color: kv.color }, kv.value)) : null))); } if (kv.type === 'static-key-value') { return (React.createElement(Box, { key: key }, React.createElement(Icon, { icon: design.icons.info }), React.createElement(StaticKeyValue, { key: key, ...kv }))); } return false; }); } function Infos({ design, error, keyValuePairs, }) { return keyValuePairs.map((kv) => { const key = `${kv.label}-${kv.value}`; if (kv.type === 'message') { return React.createElement(SimpleMessage, { key: key, ...kv }); } if (kv.type === 'dynamic-key-value') { return (React.createElement(Box, { key: key, flexWrap: "wrap" }, React.createElement(SpinnerOrErrorOrChildren, { error: error, label: `${kv.label}:`, labelPosition: "left", type: design.spinners.info, design: design }, kv.value ? (React.createElement(Text, { bold: kv.isBold, color: kv.color }, kv.value)) : null))); } if (kv.type === 'static-key-value') { return React.createElement(StaticKeyValue, { key: key, ...kv }); } return false; }); } function CompactStage({ design, direction = 'row', error, stage, stageSpecificBlock, stageTracker, status, }) { if (status !== 'current') return false; return (React.createElement(Box, { flexDirection: direction }, React.createElement(SpinnerOrError, { error: error, label: `[${stageTracker.indexOf(stage) + 1}/${stageTracker.size}] ${stage}`, type: design.spinners.stage, design: design }), stageSpecificBlock && stageSpecificBlock.length > 0 ? (React.createElement(Box, { flexDirection: "column" }, React.createElement(StageInfos, { design: design, error: error, keyValuePairs: stageSpecificBlock, stage: stage }))) : null)); } function Stage({ design, error, stage, status, }) { return (React.createElement(Box, { flexWrap: "wrap" }, (status === 'current' || status === 'failed') && (React.createElement(SpinnerOrError, { error: error, label: stage, type: design.spinners.stage, design: design })), status === 'skipped' && (React.createElement(Icon, { icon: design.icons.skipped }, React.createElement(Text, { color: "dim" }, stage, " - Skipped"))), status !== 'skipped' && status !== 'failed' && status !== 'current' && (React.createElement(Icon, { icon: design.icons[status] }, React.createElement(Text, null, stage))))); } function StageEntries({ compactionLevel, design, error, hasStageTime, stageSpecificBlock, stageTracker, timerUnit, }) { return (React.createElement(React.Fragment, null, [...stageTracker.entries()].map(([stage, status]) => (React.createElement(Box, { key: stage, flexDirection: "column" }, React.createElement(Box, { flexWrap: "wrap" }, compactionLevel === 0 ? (React.createElement(Stage, { stage: stage, status: status, design: design, error: error })) : ( // Render the stage name, spinner, and stage specific info React.createElement(CompactStage, { stage: stage, status: status, design: design, error: error, stageSpecificBlock: stageSpecificBlock, stageTracker: stageTracker, direction: compactionLevel >= 6 ? 'row' : 'column' })), status !== 'pending' && status !== 'skipped' && hasStageTime ? (React.createElement(Box, { display: compactionLevel === 0 ? 'flex' : status === 'current' ? 'flex' : 'none' }, React.createElement(Text, null, " "), React.createElement(Timer, { color: "dim", isStopped: status === 'completed' || status === 'paused', unit: timerUnit }))) : null), compactionLevel === 0 && stageSpecificBlock && stageSpecificBlock.length > 0 && status !== 'pending' && status !== 'skipped' ? (React.createElement(StageInfos, { design: design, error: error, keyValuePairs: stageSpecificBlock, stage: stage })) : null))))); } function filterInfos(infos, compactionLevel, cutOff) { return infos.filter((info) => { // return true to keep the info if (compactionLevel < cutOff || info.neverCollapse) { return true; } return false; }); } /** * Determine the level of compaction required to render the stages component within the terminal height. * * Compaction levels: * 0 - hide nothing * 1 - only show one stage at a time, with stage specific info nested under the stage * 2 - hide the elapsed time * 3 - hide the title * 4 - hide the pre-stages block * 5 - hide the post-stages block * 6 - put the stage specific info directly next to the stage * 7 - hide the stage-specific block * 8 - reduce the padding between boxes * @returns the compaction level based on the number of lines that will be displayed */ export function determineCompactionLevel({ design = constructDesignParams(), hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, title, }, rows, columns) { // We don't have access to the exact stage time, so we're taking a conservative estimate of // 10 characters + 1 character for the space between the stage and timer, // examples: 999ms (5), 59.99s (6), 59m 59.99s (10), 23h 59m (7) const estimatedTimeLength = 11; const calculateWrappedHeight = (text) => { const wrapped = wrapAnsi(text, columns, { hard: true, trim: false, wordWrap: true }); return wrapped.split('\n').length; }; const calculateHeightOfBlock = (block) => { if (!block) return 0; return block.reduce((acc, info) => { if (info.type === 'message') { if (!info.value) return acc; if (info.value.length > columns) { // if the message is longer than the terminal width, add the number of lines return acc + calculateWrappedHeight(info.value); } // if the message is multiline, add the number of lines return acc + info.value.split('\n').length; } const { label = '', value } = info; // if there's no value we still add 1 for the label if (!value) return acc + 1; const totalLength = `${label}: ${value}`.length; if (totalLength > columns) { // if the value is longer than the terminal width, add the number of lines return acc + calculateWrappedHeight(`${label}: ${value}`); } return acc + value.split('\n').length; }, 0); }; const calculateHeightOfStage = (stage) => { const status = stageTracker.get(stage) ?? 'pending'; const skipped = status === 'skipped' ? ' - Skipped' : ''; const stageTimeLength = hasStageTime ? estimatedTimeLength : 0; const parts = [ ' '.repeat(design.icons[status].paddingLeft), design.icons[status].figure, ' '.repeat(design.icons[status].paddingRight), stage, skipped, '0'.repeat(stageTimeLength), ]; return calculateWrappedHeight(parts.join('')); }; const calculateWidthOfCompactStage = (stage) => { const status = stageTracker.get(stage) ?? 'current'; // We don't have access to the exact stage time, so we're taking a conservative estimate of // 7 characters + 1 character for the space between the stage and timer, // examples: 999ms (5), 59s (3), 59m 59s (7), 23h 59m (7) const stageTimeLength = hasStageTime ? 8 : 0; const firstStageSpecificBlock = stageSpecificBlock?.find((block) => block.stage === stage); const firstStageSpecificBlockLength = firstStageSpecificBlock?.type === 'message' ? (firstStageSpecificBlock?.value?.length ?? 0) : (firstStageSpecificBlock?.label?.length ?? 0) + (firstStageSpecificBlock?.value?.length ?? 0) + 2; const width = // 1 for the left margin 1 + design.icons[status].paddingLeft + design.icons[status].figure.length + design.icons[status].paddingRight + `[${stageTracker.indexOf(stage) + 1}/${stageTracker.size}] ${stage}`.length + stageTimeLength + firstStageSpecificBlockLength; return width; }; const stagesHeight = [...stageTracker.values()].reduce((acc, stage) => acc + calculateHeightOfStage(stage), 0); const preStagesBlockHeight = calculateHeightOfBlock(preStagesBlock); const postStagesBlockHeight = calculateHeightOfBlock(postStagesBlock); const stageSpecificBlockHeight = calculateHeightOfBlock(stageSpecificBlock); // 3 at minimum because: 1 for marginTop on entire component, 1 for marginBottom on entire component, 1 for paddingBottom on StageEntries const paddings = 3 + (preStagesBlock ? 1 : 0) + (postStagesBlock ? 1 : 0) + (title ? 1 : 0); const elapsedTimeHeight = hasElapsedTime ? calculateWrappedHeight(`Elapsed Time:${'0'.repeat(estimatedTimeLength)}`) : 0; const titleHeight = title ? calculateWrappedHeight(title) : 0; const totalHeight = stagesHeight + preStagesBlockHeight + postStagesBlockHeight + stageSpecificBlockHeight + elapsedTimeHeight + titleHeight + paddings + // add one for good measure - iTerm2 will flicker on every render if the height is exactly the same as the terminal height so it's better to be safe 1; let cLevel = 0; const levels = [ // 1: only current stages, with stage specific info nested under the stage (remainingHeight) => remainingHeight - stagesHeight + Math.max(stageTracker.current.length, 1), // 2: hide the elapsed time (remainingHeight) => remainingHeight - 1, // 3: hide the title (subtract 1 for title and 1 for paddingBottom) (remainingHeight) => remainingHeight - 2, // 4: hide the pre-stages block (subtract 1 for paddingBottom) (remainingHeight) => remainingHeight - preStagesBlockHeight - 1, // 5: hide the post-stages block (remainingHeight) => remainingHeight - postStagesBlockHeight, // 6: put the stage specific info directly next to the stage (remainingHeight) => remainingHeight - stageSpecificBlockHeight, // 7: hide the stage-specific block (remainingHeight) => remainingHeight - stageSpecificBlockHeight, // 8: reduce the padding between boxes (remainingHeight) => remainingHeight - 1, ]; let remainingHeight = totalHeight; while (cLevel < 8 && remainingHeight >= rows) { remainingHeight = levels[cLevel](remainingHeight); cLevel++; } // It's possible that the collapsed stage might extend beyond the terminal width. // If so, we need to bump the compaction level up to 7 so that the stage specific info is hidden if (cLevel === 6 && stageTracker.current.map((c) => calculateWidthOfCompactStage(c)).reduce((acc, width) => acc + width, 0) >= columns) { cLevel = 7; } return { compactionLevel: cLevel, totalHeight, }; } class ErrorBoundary extends React.Component { state = { hasError: false, }; static getDerivedStateFromError() { // Update state so the next render will show the fallback UI. return { hasError: true }; } componentDidCatch(error, info) { getLogger('multi-stage-output').debug(error); getLogger('multi-stage-output').debug(info); } render() { if (this.state.hasError) { if (this.props.getFallbackText) { return React.createElement(Text, null, this.props.getFallbackText()); } return false; } return this.props.children; } } export function Stages({ compactionLevel, design = constructDesignParams(), error, hasElapsedTime = true, hasStageTime = true, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, timerUnit = 'ms', title, }) { const { stdout } = useStdout(); const [levelOfCompaction, setLevelOfCompaction] = React.useState(determineCompactionLevel({ hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, title, }, stdout.rows - 1, stdout.columns - 1).compactionLevel); React.useEffect(() => { setLevelOfCompaction(determineCompactionLevel({ hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, title, }, stdout.rows - 1, stdout.columns - 1).compactionLevel); }, [ compactionLevel, hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, stdout.columns, stdout.rows, title, ]); React.useEffect(() => { const handler = () => { setLevelOfCompaction(determineCompactionLevel({ hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, title, }, stdout.rows - 1, stdout.columns - 1).compactionLevel); }; stdout.on('resize', handler); return () => { stdout.removeListener('resize', handler); }; }); // if compactionLevel is provided, use that instead of the calculated level const actualLevelOfCompaction = compactionLevel ?? levelOfCompaction; // filter out the info blocks based on the compaction level const preStages = filterInfos(preStagesBlock ?? [], actualLevelOfCompaction, 4); const postStages = filterInfos(postStagesBlock ?? [], actualLevelOfCompaction, 5); const stageSpecific = filterInfos(stageSpecificBlock ?? [], actualLevelOfCompaction, 7); // Reduce padding if the compaction level is 8 const padding = actualLevelOfCompaction >= 8 ? 0 : 1; return (React.createElement(Box, { flexDirection: "column", marginTop: padding, marginBottom: padding }, actualLevelOfCompaction < 3 && title ? (React.createElement(Box, { paddingBottom: padding }, React.createElement(ErrorBoundary, { getFallbackText: () => title }, React.createElement(Divider, { title: title, ...design.title, terminalWidth: stdout.columns })))) : null, preStages && preStages.length > 0 ? (React.createElement(Box, { flexDirection: "column", marginLeft: 1, paddingBottom: padding }, React.createElement(ErrorBoundary, { getFallbackText: () => preStages.map((s) => (s.label ? `${s.label}: ${s.value}` : s.value)).join('\n') }, React.createElement(Infos, { design: design, error: error, keyValuePairs: preStages })))) : null, React.createElement(Box, { flexDirection: "column", marginLeft: 1, paddingBottom: padding }, React.createElement(ErrorBoundary, { getFallbackText: () => stageTracker.current[0] ?? 'unknown' }, React.createElement(StageEntries, { compactionLevel: actualLevelOfCompaction, design: design, error: error, hasStageTime: hasStageTime, stageSpecificBlock: stageSpecific, stageTracker: stageTracker, timerUnit: timerUnit }))), postStages && postStages.length > 0 ? (React.createElement(Box, { flexDirection: "column", marginLeft: 1 }, React.createElement(ErrorBoundary, { getFallbackText: () => postStages.map((s) => (s.label ? `${s.label}: ${s.value}` : s.value)).join('\n') }, React.createElement(Infos, { design: design, error: error, keyValuePairs: postStages })))) : null, hasElapsedTime ? (React.createElement(Box, { marginLeft: 1, display: actualLevelOfCompaction < 2 ? 'flex' : 'none', flexWrap: "wrap" }, React.createElement(ErrorBoundary, null, React.createElement(Text, null, "Elapsed Time: "), React.createElement(Timer, { unit: timerUnit })))) : null)); }