@oclif/multi-stage-output
Version:
Terminal output for oclif commands with multiple stages
342 lines (341 loc) • 18 kB
JavaScript
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));
}