@oclif/multi-stage-output 0.3.4 → 0.4.1-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/lib/components/divider.d.ts +2 -1
- package/lib/components/divider.js +10 -4
- package/lib/components/spinner.d.ts +4 -4
- package/lib/components/spinner.js +2 -2
- package/lib/components/stages.d.ts +30 -5
- package/lib/components/stages.js +245 -26
- package/lib/design.d.ts +16 -4
- package/lib/design.js +21 -0
- package/lib/multi-stage-output.d.ts +53 -4
- package/lib/multi-stage-output.js +86 -15
- package/lib/stage-tracker.d.ts +10 -3
- package/lib/stage-tracker.js +22 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
This is a framework for showing multi-stage output in the terminal. It's integrated with oclif's builtin [Performance](https://oclif.io/docs/performance/) capabilities so that perf metrics are automatically captured for each stage.
|
|
10
10
|
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
# Features
|
|
14
|
+
|
|
15
|
+
- Integrated Performance Tracking: this is integrated with oclif's builtin [Performance](https://oclif.io/docs/performance/) capabilities so that perf metrics are automatically captured for each stage.
|
|
16
|
+
- Responsive Design: elements will be added or removed based on the height of the terminal window. It even resizes itself if you resize the screen while the command is running.
|
|
17
|
+
- CI Friendly Output: a simpler output will be shown inside non-tty environments like CI systems to avoid any excessive output that might be hard to read in CI logs.
|
|
18
|
+
|
|
11
19
|
# Examples
|
|
12
20
|
|
|
13
21
|
You can see examples of how to use it in the [examples](./examples/) directory.
|
|
@@ -18,6 +26,153 @@ You can run any of these with the following:
|
|
|
18
26
|
node --loader=ts-node/esm examples/basic.ts
|
|
19
27
|
```
|
|
20
28
|
|
|
29
|
+
# Usage
|
|
30
|
+
|
|
31
|
+
## Basic Usage
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
const ms = new MultiStageOutput({
|
|
35
|
+
jsonEnabled: false,
|
|
36
|
+
stages: ['stage 1', 'stage 2', 'stage 3'],
|
|
37
|
+
title: 'Basic Example',
|
|
38
|
+
})
|
|
39
|
+
// goto `stage 1`
|
|
40
|
+
ms.goto('stage 1')
|
|
41
|
+
// do some stuff
|
|
42
|
+
|
|
43
|
+
// goto `stage 2`
|
|
44
|
+
ms.goto('stage 2')
|
|
45
|
+
|
|
46
|
+
// As a convenience, use .next() to goto the next stage
|
|
47
|
+
ms.next()
|
|
48
|
+
|
|
49
|
+
// stop the multi-stage output from running anymore. Pass in an Error if applicable.
|
|
50
|
+
ms.stop()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Adding information blocks before or after the stages output.
|
|
54
|
+
|
|
55
|
+
You can add blocks on information before or after the list of stages. There are 3 kinds of information that can be displayed:
|
|
56
|
+
|
|
57
|
+
- `message`: a simple string message
|
|
58
|
+
- `static-key-value`: a simple key:value pair, e.g. `name: Foo`. If the value is undefined, the key will not be shown.
|
|
59
|
+
- `dynamic-key-value`: a key:value pair where the value is expected to come in at an unknown time. This will display a spinner until the value is provided. If `.stop` is called with an error before the value is provided, then a `✘` will be displayed.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const ms = new MultiStageOutput<{message: string; staticValue: string; dynamicValue: string}>({
|
|
63
|
+
jsonEnabled: false,
|
|
64
|
+
stages: ['stage 1', 'stage 2', 'stage 3'],
|
|
65
|
+
// preStagesBlock will be displayed BEFORE the list of stages
|
|
66
|
+
postStagesBlock: [
|
|
67
|
+
{
|
|
68
|
+
get: (data) => data?.message,
|
|
69
|
+
type: 'message',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
// postStagesBlock will be displayed AFTER the list of stages
|
|
73
|
+
postStagesBlock: [
|
|
74
|
+
{
|
|
75
|
+
get: (data) => data?.staticValue,
|
|
76
|
+
label: 'Static',
|
|
77
|
+
type: 'static-key-value',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
get: (data) => data?.dynamicValue,
|
|
81
|
+
label: 'Dynamic',
|
|
82
|
+
type: 'dynamic-key-value',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
})
|
|
86
|
+
// Goto `stage 1` and provide partial data to use for the information blocks
|
|
87
|
+
ms.goto('stage 1', {message: 'This is a message', staticValue: 'This is a static key:value pair'})
|
|
88
|
+
|
|
89
|
+
// Provide more data to use
|
|
90
|
+
ms.updateData({dynamicValue: 'This is a dynamic key:value pair'})
|
|
91
|
+
|
|
92
|
+
// Goto `stage 2` and provide more partial data
|
|
93
|
+
ms.goto('stage 2')
|
|
94
|
+
|
|
95
|
+
// Goto stage 3
|
|
96
|
+
ms.goto('stage 3')
|
|
97
|
+
|
|
98
|
+
ms.stop()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Adding information blocks on a specific stage
|
|
102
|
+
|
|
103
|
+
You can also add information blocks onto specific stages, which will nest the information underneath the stage.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
const ms = new MultiStageOutput<{message: string; staticValue: string; dynamicValue: string}>({
|
|
107
|
+
jsonEnabled: false,
|
|
108
|
+
stageSpecificBlock: [
|
|
109
|
+
// This will be nested underneath `stage 1`
|
|
110
|
+
{
|
|
111
|
+
get: (data) => data?.message,
|
|
112
|
+
stage: 'one',
|
|
113
|
+
type: 'message',
|
|
114
|
+
},
|
|
115
|
+
// This will be nested underneath `stage 2`
|
|
116
|
+
{
|
|
117
|
+
get: (data) => data?.staticValue,
|
|
118
|
+
label: 'Static',
|
|
119
|
+
stage: 'two',
|
|
120
|
+
type: 'static-key-value',
|
|
121
|
+
},
|
|
122
|
+
// This will be nested underneath `stage 1`
|
|
123
|
+
{
|
|
124
|
+
get: (data) => data?.dynamicValue,
|
|
125
|
+
label: 'Dynamic',
|
|
126
|
+
stage: 'one',
|
|
127
|
+
type: 'dynamic-key-value',
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
stages: ['stage 1', 'stage 2', 'stage 3'],
|
|
131
|
+
title: 'Stage-Specific Information Block Example',
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
ms.goto('stage 1', {message: 'This is a message', staticValue: 'This is a static key:value pair'})
|
|
135
|
+
|
|
136
|
+
ms.goto('stage 2', {dynamicValue: 'This is a dynamic key:value pair'})
|
|
137
|
+
|
|
138
|
+
ms.goto('stage 3')
|
|
139
|
+
|
|
140
|
+
ms.stop()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Customizing the Design
|
|
144
|
+
|
|
145
|
+
You can customize the design of the multi-stage output using the `design` property:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const ms = new MultiStageOutput({
|
|
149
|
+
jsonEnabled: false,
|
|
150
|
+
stages: ['stage 1', 'stage 2', 'stage 3'],
|
|
151
|
+
title: 'Basic Example',
|
|
152
|
+
design: {
|
|
153
|
+
title: {
|
|
154
|
+
textColor: 'red',
|
|
155
|
+
}
|
|
156
|
+
icons: {
|
|
157
|
+
completed: {
|
|
158
|
+
figure: 'C',
|
|
159
|
+
paddingLeft: 1,
|
|
160
|
+
color: '#00FF00'
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
See [`Design`](./src/design.ts) for all the options available to you.
|
|
168
|
+
|
|
169
|
+
## Other Options
|
|
170
|
+
|
|
171
|
+
- `showElapsedTime`: Optional. Whether or not to show the `Elapsed Time` at the bottom. Defaults to `true`
|
|
172
|
+
- `showStageTime`: Optional. Whether or not to show the time for each stage. Defaults to `true`
|
|
173
|
+
- `title`: Optional. The title to show at the top.
|
|
174
|
+
- `timerUnit`: The unit to use for the elapsed time and stage time. Can be `ms` or `s`. Defaults to `ms`
|
|
175
|
+
|
|
21
176
|
# Contributing
|
|
22
177
|
|
|
23
178
|
See the [contributing guide](./CONRTIBUTING.md).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
export declare function Divider({ dividerChar, dividerColor, padding, textColor, textPadding
|
|
2
|
+
export declare function Divider({ dividerChar, dividerColor, padding, terminalWidth, textColor, textPadding, title, width, }: {
|
|
3
3
|
readonly title?: string;
|
|
4
4
|
readonly width?: number | 'full';
|
|
5
5
|
readonly padding?: number;
|
|
@@ -7,4 +7,5 @@ export declare function Divider({ dividerChar, dividerColor, padding, textColor,
|
|
|
7
7
|
readonly textPadding?: number;
|
|
8
8
|
readonly dividerChar?: string;
|
|
9
9
|
readonly dividerColor?: string;
|
|
10
|
+
readonly terminalWidth?: number;
|
|
10
11
|
}): React.ReactNode;
|
|
@@ -3,11 +3,17 @@ import React from 'react';
|
|
|
3
3
|
const getSideDividerWidth = (width, titleWidth) => (width - titleWidth) / 2;
|
|
4
4
|
const getNumberOfCharsPerWidth = (char, width) => width / char.length;
|
|
5
5
|
const PAD = ' ';
|
|
6
|
-
export function Divider({ dividerChar = '─', dividerColor = 'dim', padding = 1, textColor, textPadding
|
|
7
|
-
const titleString = title ? `${PAD.repeat(
|
|
6
|
+
export function Divider({ dividerChar = '─', dividerColor = 'dim', padding = 1, terminalWidth = process.stdout.columns ?? 80, textColor, textPadding = 1, title = '', width = 50, }) {
|
|
7
|
+
const titleString = title ? `${PAD.repeat(textPadding) + title + PAD.repeat(textPadding)}` : '';
|
|
8
8
|
const titleWidth = titleString.length;
|
|
9
|
-
const
|
|
10
|
-
|
|
9
|
+
const widthToUse = width === 'full'
|
|
10
|
+
? // if the width is `full`, use the terminal width minus the padding and title padding
|
|
11
|
+
terminalWidth - textPadding - padding
|
|
12
|
+
: // otherwise, if the provided width is greater than the terminal width, use the terminal width minus the padding and title paddding
|
|
13
|
+
width > terminalWidth
|
|
14
|
+
? terminalWidth - textPadding - padding
|
|
15
|
+
: // otherwise, use the provided width
|
|
16
|
+
width;
|
|
11
17
|
const dividerWidth = getSideDividerWidth(widthToUse, titleWidth);
|
|
12
18
|
const numberOfCharsPerSide = getNumberOfCharsPerWidth(dividerChar, dividerWidth);
|
|
13
19
|
const dividerSideString = dividerChar.repeat(numberOfCharsPerSide);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SpinnerName } from 'cli-spinners';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { RequiredDesign } from '../design.js';
|
|
4
4
|
type UseSpinnerProps = {
|
|
5
5
|
/**
|
|
6
6
|
* Type of a spinner.
|
|
@@ -19,13 +19,13 @@ type SpinnerProps = UseSpinnerProps & {
|
|
|
19
19
|
readonly labelPosition?: 'left' | 'right';
|
|
20
20
|
};
|
|
21
21
|
export declare function Spinner({ isBold, label, labelPosition, type }: SpinnerProps): React.ReactElement;
|
|
22
|
-
export declare function SpinnerOrError({
|
|
22
|
+
export declare function SpinnerOrError({ design, error, labelPosition, ...props }: SpinnerProps & {
|
|
23
23
|
readonly error?: Error;
|
|
24
|
-
readonly
|
|
24
|
+
readonly design: RequiredDesign;
|
|
25
25
|
}): React.ReactElement;
|
|
26
26
|
export declare function SpinnerOrErrorOrChildren({ children, error, ...props }: SpinnerProps & {
|
|
27
27
|
readonly children?: React.ReactNode;
|
|
28
28
|
readonly error?: Error;
|
|
29
|
-
readonly
|
|
29
|
+
readonly design: RequiredDesign;
|
|
30
30
|
}): React.ReactElement;
|
|
31
31
|
export {};
|
|
@@ -31,13 +31,13 @@ export function Spinner({ isBold, label, labelPosition = 'right', type }) {
|
|
|
31
31
|
" ",
|
|
32
32
|
label)));
|
|
33
33
|
}
|
|
34
|
-
export function SpinnerOrError({
|
|
34
|
+
export function SpinnerOrError({ design, error, labelPosition = 'right', ...props }) {
|
|
35
35
|
if (error) {
|
|
36
36
|
return (React.createElement(Box, null,
|
|
37
37
|
props.label && labelPosition === 'left' && React.createElement(Text, null,
|
|
38
38
|
props.label,
|
|
39
39
|
" "),
|
|
40
|
-
React.createElement(Icon, { icon:
|
|
40
|
+
React.createElement(Icon, { icon: design.icons.failed }),
|
|
41
41
|
props.label && labelPosition === 'right' && React.createElement(Text, null,
|
|
42
42
|
" ",
|
|
43
43
|
props.label)));
|
|
@@ -24,6 +24,10 @@ type Info<T extends Record<string, unknown>> = {
|
|
|
24
24
|
* Whether the value should be bold.
|
|
25
25
|
*/
|
|
26
26
|
bold?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Set to `true` to prevent this key-value pair or message from being collapsed when the window is too short. Defaults to false.
|
|
29
|
+
*/
|
|
30
|
+
neverCollapse?: boolean;
|
|
27
31
|
};
|
|
28
32
|
export type KeyValuePair<T extends Record<string, unknown>> = Info<T> & {
|
|
29
33
|
/**
|
|
@@ -48,18 +52,39 @@ export type FormattedKeyValue = {
|
|
|
48
52
|
readonly value: string | undefined;
|
|
49
53
|
readonly stage?: string;
|
|
50
54
|
readonly type: 'dynamic-key-value' | 'static-key-value' | 'message';
|
|
55
|
+
readonly neverCollapse?: boolean;
|
|
51
56
|
};
|
|
52
57
|
export type StagesProps = {
|
|
58
|
+
readonly compactionLevel?: number;
|
|
53
59
|
readonly design?: RequiredDesign;
|
|
54
60
|
readonly error?: Error | undefined;
|
|
61
|
+
readonly hasElapsedTime?: boolean;
|
|
62
|
+
readonly hasStageTime?: boolean;
|
|
55
63
|
readonly postStagesBlock?: FormattedKeyValue[];
|
|
56
64
|
readonly preStagesBlock?: FormattedKeyValue[];
|
|
57
65
|
readonly stageSpecificBlock?: FormattedKeyValue[];
|
|
58
|
-
readonly title?: string;
|
|
59
|
-
readonly hasElapsedTime?: boolean;
|
|
60
|
-
readonly hasStageTime?: boolean;
|
|
61
|
-
readonly timerUnit?: 'ms' | 's';
|
|
62
66
|
readonly stageTracker: StageTracker;
|
|
67
|
+
readonly timerUnit?: 'ms' | 's';
|
|
68
|
+
readonly title?: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Determine the level of compaction required to render the stages component within the terminal height.
|
|
72
|
+
*
|
|
73
|
+
* Compaction levels:
|
|
74
|
+
* 0 - hide nothing
|
|
75
|
+
* 1 - only show one stage at a time, with stage specific info nested under the stage
|
|
76
|
+
* 2 - hide the elapsed time
|
|
77
|
+
* 3 - hide the title
|
|
78
|
+
* 4 - hide the pre-stages block
|
|
79
|
+
* 5 - hide the post-stages block
|
|
80
|
+
* 6 - put the stage specific info directly next to the stage
|
|
81
|
+
* 7 - hide the stage-specific block
|
|
82
|
+
* 8 - reduce the padding between boxes
|
|
83
|
+
* @returns the compaction level based on the number of lines that will be displayed
|
|
84
|
+
*/
|
|
85
|
+
export declare function determineCompactionLevel({ design, hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, title, }: StagesProps, rows: number, columns: number): {
|
|
86
|
+
compactionLevel: number;
|
|
87
|
+
totalHeight: number;
|
|
63
88
|
};
|
|
64
|
-
export declare function Stages({ design, error, hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, timerUnit, title, }: StagesProps): React.ReactNode;
|
|
89
|
+
export declare function Stages({ compactionLevel, design, error, hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, timerUnit, title, }: StagesProps): React.ReactNode;
|
|
65
90
|
export {};
|
package/lib/components/stages.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { capitalCase } from 'change-case';
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { constructDesignParams } from '../design.js';
|
|
5
5
|
import { Divider } from './divider.js';
|
|
@@ -33,7 +33,7 @@ function StageInfos({ design, error, keyValuePairs, stage, }) {
|
|
|
33
33
|
if (kv.type === 'dynamic-key-value') {
|
|
34
34
|
return (React.createElement(Box, { key: key },
|
|
35
35
|
React.createElement(Icon, { icon: design.icons.info }),
|
|
36
|
-
React.createElement(SpinnerOrErrorOrChildren, { error: error, label: `${kv.label}:`, labelPosition: "left", type: design.spinners.info,
|
|
36
|
+
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)))));
|
|
37
37
|
}
|
|
38
38
|
if (kv.type === 'static-key-value') {
|
|
39
39
|
return (React.createElement(Box, { key: key },
|
|
@@ -50,7 +50,7 @@ function Infos({ design, error, keyValuePairs, }) {
|
|
|
50
50
|
return React.createElement(SimpleMessage, { key: key, ...kv });
|
|
51
51
|
}
|
|
52
52
|
if (kv.type === 'dynamic-key-value') {
|
|
53
|
-
return (React.createElement(SpinnerOrErrorOrChildren, { key: key, error: error, label: `${kv.label}:`, labelPosition: "left", type: design.spinners.info,
|
|
53
|
+
return (React.createElement(SpinnerOrErrorOrChildren, { key: key, 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))));
|
|
54
54
|
}
|
|
55
55
|
if (kv.type === 'static-key-value') {
|
|
56
56
|
return React.createElement(StaticKeyValue, { key: key, ...kv });
|
|
@@ -58,29 +58,248 @@ function Infos({ design, error, keyValuePairs, }) {
|
|
|
58
58
|
return false;
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
React.createElement(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
React.createElement(
|
|
83
|
-
|
|
61
|
+
function CompactStage({ design, direction = 'row', error, stage, stageSpecificBlock, stageTracker, status, }) {
|
|
62
|
+
if (status !== 'current')
|
|
63
|
+
return false;
|
|
64
|
+
return (React.createElement(Box, { flexDirection: direction },
|
|
65
|
+
React.createElement(SpinnerOrError, { error: error, label: `[${stageTracker.indexOf(stage) + 1}/${stageTracker.size}] ${capitalCase(stage)}`, type: design.spinners.stage, design: design }),
|
|
66
|
+
stageSpecificBlock && stageSpecificBlock.length > 0 && (React.createElement(Box, { flexDirection: "column" },
|
|
67
|
+
React.createElement(StageInfos, { design: design, error: error, keyValuePairs: stageSpecificBlock, stage: stage })))));
|
|
68
|
+
}
|
|
69
|
+
function Stage({ design, error, stage, status, }) {
|
|
70
|
+
return (React.createElement(Box, null,
|
|
71
|
+
(status === 'current' || status === 'failed') && (React.createElement(SpinnerOrError, { error: error, label: capitalCase(stage), type: design.spinners.stage, design: design })),
|
|
72
|
+
status === 'skipped' && (React.createElement(Icon, { icon: design.icons.skipped },
|
|
73
|
+
React.createElement(Text, { color: "dim" },
|
|
74
|
+
capitalCase(stage),
|
|
75
|
+
" - Skipped"))),
|
|
76
|
+
status !== 'skipped' && status !== 'failed' && status !== 'current' && (React.createElement(Icon, { icon: design.icons[status] },
|
|
77
|
+
React.createElement(Text, null, capitalCase(stage))))));
|
|
78
|
+
}
|
|
79
|
+
function StageEntries({ compactionLevel, design, error, hasStageTime, stageSpecificBlock, stageTracker, timerUnit, }) {
|
|
80
|
+
return (React.createElement(React.Fragment, null, [...stageTracker.entries()].map(([stage, status]) => (React.createElement(Box, { key: stage, flexDirection: "column" },
|
|
81
|
+
React.createElement(Box, null,
|
|
82
|
+
compactionLevel === 0 ? (React.createElement(Stage, { stage: stage, status: status, design: design, error: error })) : (
|
|
83
|
+
// Render the stage name, spinner, and stage specific info
|
|
84
|
+
React.createElement(CompactStage, { stage: stage, status: status, design: design, error: error, stageSpecificBlock: stageSpecificBlock, stageTracker: stageTracker, direction: compactionLevel >= 6 ? 'row' : 'column' })),
|
|
85
|
+
status !== 'pending' && status !== 'skipped' && hasStageTime && (React.createElement(Box, { display: compactionLevel === 0 ? 'flex' : status === 'current' ? 'flex' : 'none' },
|
|
86
|
+
React.createElement(Text, null, " "),
|
|
87
|
+
React.createElement(Timer, { color: "dim", isStopped: status === 'completed', unit: timerUnit })))),
|
|
88
|
+
compactionLevel === 0 &&
|
|
89
|
+
stageSpecificBlock &&
|
|
90
|
+
stageSpecificBlock.length > 0 &&
|
|
91
|
+
status !== 'pending' &&
|
|
92
|
+
status !== 'skipped' && (React.createElement(StageInfos, { design: design, error: error, keyValuePairs: stageSpecificBlock, stage: stage })))))));
|
|
93
|
+
}
|
|
94
|
+
function filterInfos(infos, compactionLevel, cutOff) {
|
|
95
|
+
return infos.filter((info) => {
|
|
96
|
+
// return true to keep the info
|
|
97
|
+
if (compactionLevel < cutOff || info.neverCollapse) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Determine the level of compaction required to render the stages component within the terminal height.
|
|
105
|
+
*
|
|
106
|
+
* Compaction levels:
|
|
107
|
+
* 0 - hide nothing
|
|
108
|
+
* 1 - only show one stage at a time, with stage specific info nested under the stage
|
|
109
|
+
* 2 - hide the elapsed time
|
|
110
|
+
* 3 - hide the title
|
|
111
|
+
* 4 - hide the pre-stages block
|
|
112
|
+
* 5 - hide the post-stages block
|
|
113
|
+
* 6 - put the stage specific info directly next to the stage
|
|
114
|
+
* 7 - hide the stage-specific block
|
|
115
|
+
* 8 - reduce the padding between boxes
|
|
116
|
+
* @returns the compaction level based on the number of lines that will be displayed
|
|
117
|
+
*/
|
|
118
|
+
export function determineCompactionLevel({ design = constructDesignParams(), hasElapsedTime, hasStageTime, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, title, }, rows, columns) {
|
|
119
|
+
const calculateHeightOfBlock = (block) => {
|
|
120
|
+
if (!block)
|
|
121
|
+
return 0;
|
|
122
|
+
return block.reduce((acc, info) => {
|
|
123
|
+
if (info.type === 'message') {
|
|
124
|
+
if (!info.value)
|
|
125
|
+
return acc;
|
|
126
|
+
if (info.value.length > columns) {
|
|
127
|
+
// if the message is longer than the terminal width, add the number of lines
|
|
128
|
+
return acc + Math.ceil(info.value.length / columns);
|
|
129
|
+
}
|
|
130
|
+
// if the message is multiline, add the number of lines
|
|
131
|
+
return acc + info.value.split('\n').length;
|
|
132
|
+
}
|
|
133
|
+
const { label = '', value } = info;
|
|
134
|
+
// if there's no value we still add 1 for the label
|
|
135
|
+
if (!value)
|
|
136
|
+
return acc + 1;
|
|
137
|
+
if (label.length + Number(': '.length) + value.length > columns) {
|
|
138
|
+
// if the value is longer than the terminal width, add the number of lines
|
|
139
|
+
return acc + Math.ceil(value.length / columns);
|
|
140
|
+
}
|
|
141
|
+
return acc + value.split('\n').length;
|
|
142
|
+
}, 0);
|
|
143
|
+
};
|
|
144
|
+
const calculateHeightOfStage = (stage) => {
|
|
145
|
+
const status = stageTracker.get(stage) ?? 'pending';
|
|
146
|
+
const skipped = status === 'skipped' ? ' - Skipped' : '';
|
|
147
|
+
// We don't have access to the exact stage time, so we're taking a conservative estimate of
|
|
148
|
+
// 10 characters + 1 character for the space between the stage and timer,
|
|
149
|
+
// examples: 999ms (5), 59.99s (6), 59m 59.99s (10), 23h 59m (7)
|
|
150
|
+
const stageTimeLength = hasStageTime ? 11 : 0;
|
|
151
|
+
if (
|
|
152
|
+
// 1 for the left margin
|
|
153
|
+
1 +
|
|
154
|
+
design.icons[status].paddingLeft +
|
|
155
|
+
design.icons[status].figure.length +
|
|
156
|
+
design.icons[status].paddingRight +
|
|
157
|
+
stage.length +
|
|
158
|
+
skipped.length +
|
|
159
|
+
stageTimeLength >
|
|
160
|
+
columns) {
|
|
161
|
+
return Math.ceil(stage.length / columns);
|
|
162
|
+
}
|
|
163
|
+
return 1;
|
|
164
|
+
};
|
|
165
|
+
const calculateWidthOfCompactStage = (stage) => {
|
|
166
|
+
const status = stageTracker.get(stage) ?? 'current';
|
|
167
|
+
// We don't have access to the exact stage time, so we're taking a conservative estimate of
|
|
168
|
+
// 7 characters + 1 character for the space between the stage and timer,
|
|
169
|
+
// examples: 999ms (5), 59s (3), 59m 59s (7), 23h 59m (7)
|
|
170
|
+
const stageTimeLength = hasStageTime ? 8 : 0;
|
|
171
|
+
const firstStageSpecificBlock = stageSpecificBlock?.find((block) => block.stage === stage);
|
|
172
|
+
const firstStageSpecificBlockLength = firstStageSpecificBlock?.type === 'message'
|
|
173
|
+
? (firstStageSpecificBlock?.value?.length ?? 0)
|
|
174
|
+
: (firstStageSpecificBlock?.label?.length ?? 0) + (firstStageSpecificBlock?.value?.length ?? 0) + 2;
|
|
175
|
+
const width =
|
|
176
|
+
// 1 for the left margin
|
|
177
|
+
1 +
|
|
178
|
+
design.icons[status].paddingLeft +
|
|
179
|
+
design.icons[status].figure.length +
|
|
180
|
+
design.icons[status].paddingRight +
|
|
181
|
+
`[${stageTracker.indexOf(stage) + 1}/${stageTracker.size}] ${stage}`.length +
|
|
182
|
+
stageTimeLength +
|
|
183
|
+
firstStageSpecificBlockLength;
|
|
184
|
+
return width;
|
|
185
|
+
};
|
|
186
|
+
const stagesHeight = [...stageTracker.values()].reduce((acc, stage) => acc + calculateHeightOfStage(stage), 0);
|
|
187
|
+
const preStagesBlockHeight = calculateHeightOfBlock(preStagesBlock);
|
|
188
|
+
const postStagesBlockHeight = calculateHeightOfBlock(postStagesBlock);
|
|
189
|
+
const stageSpecificBlockHeight = calculateHeightOfBlock(stageSpecificBlock);
|
|
190
|
+
// 3 at minimum because: 1 for marginTop on entire component, 1 for marginBottom on entire component, 1 for paddingBottom on StageEntries
|
|
191
|
+
const paddings = 3 + (preStagesBlock ? 1 : 0) + (postStagesBlock ? 1 : 0) + (title ? 1 : 0);
|
|
192
|
+
const totalHeight = stagesHeight +
|
|
193
|
+
preStagesBlockHeight +
|
|
194
|
+
postStagesBlockHeight +
|
|
195
|
+
stageSpecificBlockHeight +
|
|
196
|
+
(title ? 1 : 0) +
|
|
197
|
+
(hasElapsedTime ? 1 : 0) +
|
|
198
|
+
paddings +
|
|
199
|
+
// 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
|
|
200
|
+
1;
|
|
201
|
+
let cLevel = 0;
|
|
202
|
+
const levels = [
|
|
203
|
+
// 1: only show one stage at a time, with stage specific info nested under the stage
|
|
204
|
+
(remainingHeight) => remainingHeight - stagesHeight + 1,
|
|
205
|
+
// 2: hide the elapsed time
|
|
206
|
+
(remainingHeight) => remainingHeight - 1,
|
|
207
|
+
// 3: hide the title (subtract 1 for title and 1 for paddingBottom)
|
|
208
|
+
(remainingHeight) => remainingHeight - 2,
|
|
209
|
+
// 4: hide the pre-stages block (subtract 1 for paddingBottom)
|
|
210
|
+
(remainingHeight) => remainingHeight - preStagesBlockHeight - 1,
|
|
211
|
+
// 5: hide the post-stages block
|
|
212
|
+
(remainingHeight) => remainingHeight - postStagesBlockHeight,
|
|
213
|
+
// 6: put the stage specific info directly next to the stage
|
|
214
|
+
(remainingHeight) => remainingHeight - stageSpecificBlockHeight,
|
|
215
|
+
// 7: hide the stage-specific block
|
|
216
|
+
(remainingHeight) => remainingHeight - stageSpecificBlockHeight,
|
|
217
|
+
// 8: reduce the padding between boxes
|
|
218
|
+
(remainingHeight) => remainingHeight - 1,
|
|
219
|
+
];
|
|
220
|
+
let remainingHeight = totalHeight;
|
|
221
|
+
while (cLevel < 8 && remainingHeight >= rows) {
|
|
222
|
+
remainingHeight = levels[cLevel](remainingHeight);
|
|
223
|
+
cLevel++;
|
|
224
|
+
}
|
|
225
|
+
// It's possible that the collapsed stage might extend beyond the terminal width.
|
|
226
|
+
// If so, we need to bump the compaction level up to 7 so that the stage specific info is hidden
|
|
227
|
+
if (cLevel === 6 && stageTracker.current && calculateWidthOfCompactStage(stageTracker.current) >= columns) {
|
|
228
|
+
cLevel = 7;
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
compactionLevel: cLevel,
|
|
232
|
+
totalHeight,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export function Stages({ compactionLevel, design = constructDesignParams(), error, hasElapsedTime = true, hasStageTime = true, postStagesBlock, preStagesBlock, stageSpecificBlock, stageTracker, timerUnit = 'ms', title, }) {
|
|
236
|
+
const { stdout } = useStdout();
|
|
237
|
+
const [levelOfCompaction, setLevelOfCompaction] = React.useState(determineCompactionLevel({
|
|
238
|
+
hasElapsedTime,
|
|
239
|
+
hasStageTime,
|
|
240
|
+
postStagesBlock,
|
|
241
|
+
preStagesBlock,
|
|
242
|
+
stageSpecificBlock,
|
|
243
|
+
stageTracker,
|
|
244
|
+
title,
|
|
245
|
+
}, stdout.rows - 1, stdout.columns).compactionLevel);
|
|
246
|
+
React.useEffect(() => {
|
|
247
|
+
setLevelOfCompaction(determineCompactionLevel({
|
|
248
|
+
hasElapsedTime,
|
|
249
|
+
hasStageTime,
|
|
250
|
+
postStagesBlock,
|
|
251
|
+
preStagesBlock,
|
|
252
|
+
stageSpecificBlock,
|
|
253
|
+
stageTracker,
|
|
254
|
+
title,
|
|
255
|
+
}, stdout.rows - 1, stdout.columns).compactionLevel);
|
|
256
|
+
}, [
|
|
257
|
+
compactionLevel,
|
|
258
|
+
hasElapsedTime,
|
|
259
|
+
hasStageTime,
|
|
260
|
+
postStagesBlock,
|
|
261
|
+
preStagesBlock,
|
|
262
|
+
stageSpecificBlock,
|
|
263
|
+
stageTracker,
|
|
264
|
+
stdout.columns,
|
|
265
|
+
stdout.rows,
|
|
266
|
+
title,
|
|
267
|
+
]);
|
|
268
|
+
React.useEffect(() => {
|
|
269
|
+
const handler = () => {
|
|
270
|
+
setLevelOfCompaction(determineCompactionLevel({
|
|
271
|
+
hasElapsedTime,
|
|
272
|
+
hasStageTime,
|
|
273
|
+
postStagesBlock,
|
|
274
|
+
preStagesBlock,
|
|
275
|
+
stageSpecificBlock,
|
|
276
|
+
stageTracker,
|
|
277
|
+
title,
|
|
278
|
+
}, stdout.rows - 1, stdout.columns).compactionLevel);
|
|
279
|
+
};
|
|
280
|
+
stdout.on('resize', handler);
|
|
281
|
+
return () => {
|
|
282
|
+
stdout.removeListener('resize', handler);
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
// if compactionLevel is provided, use that instead of the calculated level
|
|
286
|
+
const actualLevelOfCompaction = compactionLevel ?? levelOfCompaction;
|
|
287
|
+
// filter out the info blocks based on the compaction level
|
|
288
|
+
const preStages = filterInfos(preStagesBlock ?? [], actualLevelOfCompaction, 4);
|
|
289
|
+
const postStages = filterInfos(postStagesBlock ?? [], actualLevelOfCompaction, 5);
|
|
290
|
+
const stageSpecific = filterInfos(stageSpecificBlock ?? [], actualLevelOfCompaction, 7);
|
|
291
|
+
// Reduce padding if the compaction level is 8
|
|
292
|
+
const padding = actualLevelOfCompaction === 8 ? 0 : 1;
|
|
293
|
+
return (React.createElement(Box, { flexDirection: "column", marginTop: padding, marginBottom: padding },
|
|
294
|
+
actualLevelOfCompaction < 3 && title && (React.createElement(Box, { paddingBottom: padding },
|
|
295
|
+
React.createElement(Divider, { title: title, ...design.title, terminalWidth: stdout.columns }))),
|
|
296
|
+
preStages && preStages.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 1, paddingBottom: padding },
|
|
297
|
+
React.createElement(Infos, { design: design, error: error, keyValuePairs: preStages }))),
|
|
298
|
+
React.createElement(Box, { flexDirection: "column", marginLeft: 1, paddingBottom: padding },
|
|
299
|
+
React.createElement(StageEntries, { compactionLevel: actualLevelOfCompaction, design: design, error: error, hasStageTime: hasStageTime, stageSpecificBlock: stageSpecific, stageTracker: stageTracker, timerUnit: timerUnit })),
|
|
300
|
+
postStages && postStages.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 1 },
|
|
301
|
+
React.createElement(Infos, { design: design, error: error, keyValuePairs: postStages }))),
|
|
302
|
+
hasElapsedTime && (React.createElement(Box, { marginLeft: 1, display: actualLevelOfCompaction < 2 ? 'flex' : 'none' },
|
|
84
303
|
React.createElement(Text, null, "Elapsed Time: "),
|
|
85
304
|
React.createElement(Timer, { unit: timerUnit })))));
|
|
86
305
|
}
|
package/lib/design.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { IconProps } from './components/icon.js';
|
|
|
3
3
|
export type Design = {
|
|
4
4
|
icons?: {
|
|
5
5
|
/**
|
|
6
|
-
* Icon to display for a completed stage. Defaults to '✔'
|
|
6
|
+
* Icon to display for a completed stage. Defaults to green '✔'
|
|
7
7
|
*/
|
|
8
8
|
completed?: IconProps;
|
|
9
9
|
/**
|
|
@@ -13,21 +13,33 @@ export type Design = {
|
|
|
13
13
|
*/
|
|
14
14
|
current?: IconProps;
|
|
15
15
|
/**
|
|
16
|
-
* Icon to display for a failed stage. Defaults to '✘'
|
|
16
|
+
* Icon to display for a failed stage. Defaults to red '✘'
|
|
17
17
|
*/
|
|
18
18
|
failed?: IconProps;
|
|
19
19
|
/**
|
|
20
|
-
* Icon to display for a pending stage. Defaults to '◼'
|
|
20
|
+
* Icon to display for a pending stage. Defaults to dim '◼'
|
|
21
21
|
*/
|
|
22
22
|
pending?: IconProps;
|
|
23
23
|
/**
|
|
24
|
-
* Icon to display for a skipped stage. Defaults to '◯'
|
|
24
|
+
* Icon to display for a skipped stage. Defaults to dim '◯'
|
|
25
25
|
*/
|
|
26
26
|
skipped?: IconProps;
|
|
27
27
|
/**
|
|
28
28
|
* Icon to display for stage specific information. Defaults to '▸'
|
|
29
29
|
*/
|
|
30
30
|
info?: IconProps;
|
|
31
|
+
/**
|
|
32
|
+
* Icon to display for a aborted stage. Defaults to red '◼'
|
|
33
|
+
*/
|
|
34
|
+
aborted?: IconProps;
|
|
35
|
+
/**
|
|
36
|
+
* Icon to display for a paused stage. Defaults to magenta '●'
|
|
37
|
+
*/
|
|
38
|
+
paused?: IconProps;
|
|
39
|
+
/**
|
|
40
|
+
* Icon to display for an async stage. Defaults to magenta '▶'
|
|
41
|
+
*/
|
|
42
|
+
async?: IconProps;
|
|
31
43
|
};
|
|
32
44
|
title?: {
|
|
33
45
|
/**
|
package/lib/design.js
CHANGED
|
@@ -2,6 +2,20 @@ import figures from 'figures';
|
|
|
2
2
|
export function constructDesignParams(design) {
|
|
3
3
|
return {
|
|
4
4
|
icons: {
|
|
5
|
+
aborted: {
|
|
6
|
+
color: 'red',
|
|
7
|
+
figure: figures.squareSmallFilled,
|
|
8
|
+
paddingLeft: 0,
|
|
9
|
+
paddingRight: 0,
|
|
10
|
+
...design?.icons?.current,
|
|
11
|
+
},
|
|
12
|
+
async: {
|
|
13
|
+
color: 'magenta',
|
|
14
|
+
figure: figures.play,
|
|
15
|
+
paddingLeft: 0,
|
|
16
|
+
paddingRight: 0,
|
|
17
|
+
...design?.icons?.current,
|
|
18
|
+
},
|
|
5
19
|
completed: {
|
|
6
20
|
color: 'green',
|
|
7
21
|
figure: figures.tick,
|
|
@@ -30,6 +44,13 @@ export function constructDesignParams(design) {
|
|
|
30
44
|
paddingRight: 1,
|
|
31
45
|
...design?.icons?.info,
|
|
32
46
|
},
|
|
47
|
+
paused: {
|
|
48
|
+
color: 'magenta',
|
|
49
|
+
figure: figures.bullet,
|
|
50
|
+
paddingLeft: 0,
|
|
51
|
+
paddingRight: 1,
|
|
52
|
+
...design?.icons?.current,
|
|
53
|
+
},
|
|
33
54
|
pending: {
|
|
34
55
|
color: 'dim',
|
|
35
56
|
figure: figures.squareSmallFilled,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { KeyValuePair, SimpleMessage, StageInfoBlock } from './components/stages.js';
|
|
2
2
|
import { Design } from './design.js';
|
|
3
|
+
import { StageStatus } from './stage-tracker.js';
|
|
3
4
|
export type MultiStageOutputOptions<T extends Record<string, unknown>> = {
|
|
4
5
|
/**
|
|
5
6
|
* Stages to render.
|
|
@@ -51,28 +52,76 @@ export type MultiStageOutputOptions<T extends Record<string, unknown>> = {
|
|
|
51
52
|
readonly jsonEnabled: boolean;
|
|
52
53
|
};
|
|
53
54
|
export declare class MultiStageOutput<T extends Record<string, unknown>> implements Disposable {
|
|
54
|
-
private ciInstance;
|
|
55
|
+
private readonly ciInstance;
|
|
55
56
|
private data?;
|
|
56
57
|
private readonly design;
|
|
57
58
|
private readonly hasElapsedTime?;
|
|
58
59
|
private readonly hasStageTime?;
|
|
59
|
-
private inkInstance;
|
|
60
|
+
private readonly inkInstance;
|
|
60
61
|
private readonly postStagesBlock?;
|
|
61
62
|
private readonly preStagesBlock?;
|
|
62
63
|
private readonly stages;
|
|
63
64
|
private readonly stageSpecificBlock?;
|
|
64
|
-
private stageTracker;
|
|
65
|
+
private readonly stageTracker;
|
|
65
66
|
private stopped;
|
|
66
67
|
private readonly timerUnit?;
|
|
67
68
|
private readonly title?;
|
|
68
69
|
constructor({ data, design, jsonEnabled, postStagesBlock, preStagesBlock, showElapsedTime, showStageTime, stageSpecificBlock, stages, timerUnit, title, }: MultiStageOutputOptions<T>);
|
|
70
|
+
/**
|
|
71
|
+
* Stop multi-stage output from running with a failed status.
|
|
72
|
+
*/
|
|
73
|
+
error(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Go to a stage, marking any stages in between the current stage and the provided stage as completed.
|
|
76
|
+
*
|
|
77
|
+
* If the stage does not exist or is before the current stage, nothing will happen.
|
|
78
|
+
*
|
|
79
|
+
* If the stage is the same as the current stage, the data will be updated.
|
|
80
|
+
*
|
|
81
|
+
* @param stage Stage to go to
|
|
82
|
+
* @param data - Optional data to pass to the next stage.
|
|
83
|
+
* @returns void
|
|
84
|
+
*/
|
|
69
85
|
goto(stage: string, data?: Partial<T>): void;
|
|
86
|
+
/**
|
|
87
|
+
* Moves to the next stage of the process.
|
|
88
|
+
*
|
|
89
|
+
* @param data - Optional data to pass to the next stage.
|
|
90
|
+
* @returns void
|
|
91
|
+
*/
|
|
70
92
|
next(data?: Partial<T>): void;
|
|
71
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Go to a stage, marking any stages in between the current stage and the provided stage as skipped.
|
|
95
|
+
*
|
|
96
|
+
* If the stage does not exist or is before the current stage, nothing will happen.
|
|
97
|
+
*
|
|
98
|
+
* If the stage is the same as the current stage, the data will be updated.
|
|
99
|
+
*
|
|
100
|
+
* @param stage Stage to go to
|
|
101
|
+
* @param data - Optional data to pass to the next stage.
|
|
102
|
+
* @returns void
|
|
103
|
+
*/
|
|
104
|
+
skipTo(stage: string, data?: Partial<T>): void;
|
|
105
|
+
/**
|
|
106
|
+
* Stop multi-stage output from running.
|
|
107
|
+
*
|
|
108
|
+
* The stage currently running will be changed to the provided `finalStatus`.
|
|
109
|
+
*
|
|
110
|
+
* @param finalStatus - The status to set the current stage to.
|
|
111
|
+
* @returns void
|
|
112
|
+
*/
|
|
113
|
+
stop(finalStatus?: StageStatus): void;
|
|
72
114
|
[Symbol.dispose](): void;
|
|
115
|
+
/**
|
|
116
|
+
* Updates the data of the component.
|
|
117
|
+
*
|
|
118
|
+
* @param data - The partial data object to update the component's data with.
|
|
119
|
+
* @returns void
|
|
120
|
+
*/
|
|
73
121
|
updateData(data: Partial<T>): void;
|
|
74
122
|
private formatKeyValuePairs;
|
|
75
123
|
/** shared method to populate everything needed for Stages cmp */
|
|
76
124
|
private generateStagesInput;
|
|
125
|
+
private rerender;
|
|
77
126
|
private update;
|
|
78
127
|
}
|
|
@@ -77,7 +77,7 @@ class CIMultiStageOutput {
|
|
|
77
77
|
this.lastUpdateTime = Date.now();
|
|
78
78
|
if (!this.startTimes.has(stage))
|
|
79
79
|
this.startTimes.set(stage, Date.now());
|
|
80
|
-
ux.stdout(`${this.design.icons.current} ${capitalCase(stage)}...`);
|
|
80
|
+
ux.stdout(`${this.design.icons.current.figure} ${capitalCase(stage)}...`);
|
|
81
81
|
this.printInfo(this.preStagesBlock, 3);
|
|
82
82
|
this.printInfo(this.stageSpecificBlock?.filter((info) => info.stage === stage), 3);
|
|
83
83
|
this.printInfo(this.postStagesBlock, 3);
|
|
@@ -91,16 +91,16 @@ class CIMultiStageOutput {
|
|
|
91
91
|
const startTime = this.startTimes.get(stage);
|
|
92
92
|
const elapsedTime = startTime ? Date.now() - startTime : 0;
|
|
93
93
|
const displayTime = readableTime(elapsedTime, this.timerUnit);
|
|
94
|
-
ux.stdout(`${this.design.icons[status]} ${capitalCase(stage)} (${displayTime})`);
|
|
94
|
+
ux.stdout(`${this.design.icons[status].figure} ${capitalCase(stage)} (${displayTime})`);
|
|
95
95
|
this.printInfo(this.preStagesBlock, 3);
|
|
96
96
|
this.printInfo(this.stageSpecificBlock?.filter((info) => info.stage === stage), 3);
|
|
97
97
|
this.printInfo(this.postStagesBlock, 3);
|
|
98
98
|
}
|
|
99
99
|
else if (status === 'skipped') {
|
|
100
|
-
ux.stdout(`${this.design.icons[status]} ${capitalCase(stage)} - Skipped`);
|
|
100
|
+
ux.stdout(`${this.design.icons[status].figure} ${capitalCase(stage)} - Skipped`);
|
|
101
101
|
}
|
|
102
102
|
else {
|
|
103
|
-
ux.stdout(`${this.design.icons[status]} ${capitalCase(stage)}`);
|
|
103
|
+
ux.stdout(`${this.design.icons[status].figure} ${capitalCase(stage)}`);
|
|
104
104
|
this.printInfo(this.preStagesBlock, 3);
|
|
105
105
|
this.printInfo(this.stageSpecificBlock?.filter((info) => info.stage === stage), 3);
|
|
106
106
|
this.printInfo(this.postStagesBlock, 3);
|
|
@@ -177,6 +177,23 @@ export class MultiStageOutput {
|
|
|
177
177
|
this.inkInstance = render(React.createElement(Stages, { ...this.generateStagesInput() }));
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Stop multi-stage output from running with a failed status.
|
|
182
|
+
*/
|
|
183
|
+
error() {
|
|
184
|
+
this.stop('failed');
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Go to a stage, marking any stages in between the current stage and the provided stage as completed.
|
|
188
|
+
*
|
|
189
|
+
* If the stage does not exist or is before the current stage, nothing will happen.
|
|
190
|
+
*
|
|
191
|
+
* If the stage is the same as the current stage, the data will be updated.
|
|
192
|
+
*
|
|
193
|
+
* @param stage Stage to go to
|
|
194
|
+
* @param data - Optional data to pass to the next stage.
|
|
195
|
+
* @returns void
|
|
196
|
+
*/
|
|
180
197
|
goto(stage, data) {
|
|
181
198
|
if (this.stopped)
|
|
182
199
|
return;
|
|
@@ -186,37 +203,85 @@ export class MultiStageOutput {
|
|
|
186
203
|
// prevent going to a previous stage
|
|
187
204
|
if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0]))
|
|
188
205
|
return;
|
|
189
|
-
this.update(stage, data);
|
|
206
|
+
this.update(stage, 'completed', data);
|
|
190
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Moves to the next stage of the process.
|
|
210
|
+
*
|
|
211
|
+
* @param data - Optional data to pass to the next stage.
|
|
212
|
+
* @returns void
|
|
213
|
+
*/
|
|
191
214
|
next(data) {
|
|
192
215
|
if (this.stopped)
|
|
193
216
|
return;
|
|
194
217
|
const nextStageIndex = this.stages.indexOf(this.stageTracker.current ?? this.stages[0]) + 1;
|
|
195
218
|
if (nextStageIndex < this.stages.length) {
|
|
196
|
-
this.update(this.stages[nextStageIndex], data);
|
|
219
|
+
this.update(this.stages[nextStageIndex], 'completed', data);
|
|
197
220
|
}
|
|
198
221
|
}
|
|
199
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Go to a stage, marking any stages in between the current stage and the provided stage as skipped.
|
|
224
|
+
*
|
|
225
|
+
* If the stage does not exist or is before the current stage, nothing will happen.
|
|
226
|
+
*
|
|
227
|
+
* If the stage is the same as the current stage, the data will be updated.
|
|
228
|
+
*
|
|
229
|
+
* @param stage Stage to go to
|
|
230
|
+
* @param data - Optional data to pass to the next stage.
|
|
231
|
+
* @returns void
|
|
232
|
+
*/
|
|
233
|
+
skipTo(stage, data) {
|
|
234
|
+
if (this.stopped)
|
|
235
|
+
return;
|
|
236
|
+
// ignore non-existent stages
|
|
237
|
+
if (!this.stages.includes(stage))
|
|
238
|
+
return;
|
|
239
|
+
// prevent going to a previous stage
|
|
240
|
+
if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0]))
|
|
241
|
+
return;
|
|
242
|
+
this.update(stage, 'skipped', data);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Stop multi-stage output from running.
|
|
246
|
+
*
|
|
247
|
+
* The stage currently running will be changed to the provided `finalStatus`.
|
|
248
|
+
*
|
|
249
|
+
* @param finalStatus - The status to set the current stage to.
|
|
250
|
+
* @returns void
|
|
251
|
+
*/
|
|
252
|
+
stop(finalStatus = 'completed') {
|
|
200
253
|
if (this.stopped)
|
|
201
254
|
return;
|
|
202
255
|
this.stopped = true;
|
|
203
|
-
this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], {
|
|
256
|
+
this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], {
|
|
257
|
+
finalStatus,
|
|
258
|
+
});
|
|
204
259
|
if (isInCi) {
|
|
205
260
|
this.ciInstance?.stop(this.stageTracker);
|
|
206
261
|
return;
|
|
207
262
|
}
|
|
208
|
-
|
|
209
|
-
|
|
263
|
+
// The underlying components expect an Error, although they don't currently use anything on the error - they check if it exists.
|
|
264
|
+
// Instead of refactoring the components to take a boolean, we pass in a placeholder Error,
|
|
265
|
+
// which, gives us the flexibility in the future to pass in an actual Error if we want
|
|
266
|
+
const error = finalStatus === 'failed' ? new Error('Error') : undefined;
|
|
267
|
+
const stagesInput = { ...this.generateStagesInput({ compactionLevel: 0 }), ...(error ? { error } : {}) };
|
|
268
|
+
this.inkInstance?.rerender(React.createElement(Stages, { ...stagesInput, compactionLevel: 0 }));
|
|
210
269
|
this.inkInstance?.unmount();
|
|
211
270
|
}
|
|
212
271
|
[Symbol.dispose]() {
|
|
213
272
|
this.inkInstance?.unmount();
|
|
214
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Updates the data of the component.
|
|
276
|
+
*
|
|
277
|
+
* @param data - The partial data object to update the component's data with.
|
|
278
|
+
* @returns void
|
|
279
|
+
*/
|
|
215
280
|
updateData(data) {
|
|
216
281
|
if (this.stopped)
|
|
217
282
|
return;
|
|
218
283
|
this.data = { ...this.data, ...data };
|
|
219
|
-
this.
|
|
284
|
+
this.rerender();
|
|
220
285
|
}
|
|
221
286
|
formatKeyValuePairs(infoBlock) {
|
|
222
287
|
return (infoBlock?.map((info) => {
|
|
@@ -224,6 +289,7 @@ export class MultiStageOutput {
|
|
|
224
289
|
return {
|
|
225
290
|
color: info.color,
|
|
226
291
|
isBold: info.bold,
|
|
292
|
+
neverCollapse: info.neverCollapse,
|
|
227
293
|
type: info.type,
|
|
228
294
|
value: formattedData,
|
|
229
295
|
...(info.type === 'message' ? {} : { label: info.label }),
|
|
@@ -232,8 +298,10 @@ export class MultiStageOutput {
|
|
|
232
298
|
}) ?? []);
|
|
233
299
|
}
|
|
234
300
|
/** shared method to populate everything needed for Stages cmp */
|
|
235
|
-
generateStagesInput() {
|
|
301
|
+
generateStagesInput(opts) {
|
|
302
|
+
const { compactionLevel } = opts ?? {};
|
|
236
303
|
return {
|
|
304
|
+
compactionLevel,
|
|
237
305
|
design: this.design,
|
|
238
306
|
hasElapsedTime: this.hasElapsedTime,
|
|
239
307
|
hasStageTime: this.hasStageTime,
|
|
@@ -245,9 +313,7 @@ export class MultiStageOutput {
|
|
|
245
313
|
title: this.title,
|
|
246
314
|
};
|
|
247
315
|
}
|
|
248
|
-
|
|
249
|
-
this.data = { ...this.data, ...data };
|
|
250
|
-
this.stageTracker.refresh(stage);
|
|
316
|
+
rerender() {
|
|
251
317
|
if (isInCi) {
|
|
252
318
|
this.ciInstance?.update(this.stageTracker, this.data);
|
|
253
319
|
}
|
|
@@ -255,4 +321,9 @@ export class MultiStageOutput {
|
|
|
255
321
|
this.inkInstance?.rerender(React.createElement(Stages, { ...this.generateStagesInput() }));
|
|
256
322
|
}
|
|
257
323
|
}
|
|
324
|
+
update(stage, bypassStatus, data) {
|
|
325
|
+
this.data = { ...this.data, ...data };
|
|
326
|
+
this.stageTracker.refresh(stage, { bypassStatus });
|
|
327
|
+
this.rerender();
|
|
328
|
+
}
|
|
258
329
|
}
|
package/lib/stage-tracker.d.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
export type StageStatus = '
|
|
1
|
+
export type StageStatus = 'aborted' | 'async' | 'completed' | 'current' | 'failed' | 'paused' | 'pending' | 'skipped';
|
|
2
2
|
export declare class StageTracker {
|
|
3
|
+
private stages;
|
|
3
4
|
current: string | undefined;
|
|
4
5
|
private map;
|
|
5
6
|
private markers;
|
|
6
7
|
constructor(stages: readonly string[] | string[]);
|
|
8
|
+
get size(): number;
|
|
7
9
|
entries(): IterableIterator<[string, StageStatus]>;
|
|
8
10
|
get(stage: string): StageStatus | undefined;
|
|
11
|
+
getCurrent(): {
|
|
12
|
+
stage: string;
|
|
13
|
+
status: StageStatus;
|
|
14
|
+
} | undefined;
|
|
15
|
+
indexOf(stage: string): number;
|
|
9
16
|
refresh(nextStage: string, opts?: {
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
finalStatus?: StageStatus;
|
|
18
|
+
bypassStatus?: StageStatus;
|
|
12
19
|
}): void;
|
|
13
20
|
set(stage: string, status: StageStatus): void;
|
|
14
21
|
values(): IterableIterator<StageStatus>;
|
package/lib/stage-tracker.js
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import { Performance } from '@oclif/core/performance';
|
|
2
2
|
export class StageTracker {
|
|
3
|
+
stages;
|
|
3
4
|
current;
|
|
4
5
|
map = new Map();
|
|
5
6
|
markers = new Map();
|
|
6
7
|
constructor(stages) {
|
|
8
|
+
this.stages = stages;
|
|
7
9
|
this.map = new Map(stages.map((stage) => [stage, 'pending']));
|
|
8
10
|
}
|
|
11
|
+
get size() {
|
|
12
|
+
return this.map.size;
|
|
13
|
+
}
|
|
9
14
|
entries() {
|
|
10
15
|
return this.map.entries();
|
|
11
16
|
}
|
|
12
17
|
get(stage) {
|
|
13
18
|
return this.map.get(stage);
|
|
14
19
|
}
|
|
20
|
+
getCurrent() {
|
|
21
|
+
if (this.current) {
|
|
22
|
+
return {
|
|
23
|
+
stage: this.current,
|
|
24
|
+
status: this.map.get(this.current),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
indexOf(stage) {
|
|
29
|
+
return this.stages.indexOf(stage);
|
|
30
|
+
}
|
|
15
31
|
refresh(nextStage, opts) {
|
|
16
32
|
const stages = [...this.map.keys()];
|
|
17
33
|
for (const stage of stages) {
|
|
@@ -19,15 +35,9 @@ export class StageTracker {
|
|
|
19
35
|
continue;
|
|
20
36
|
if (this.map.get(stage) === 'failed')
|
|
21
37
|
continue;
|
|
22
|
-
// .stop() was called with
|
|
23
|
-
if (nextStage === stage && opts?.
|
|
24
|
-
this.set(stage,
|
|
25
|
-
this.stopMarker(stage);
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
// .stop() was called without an error => set the stage to completed
|
|
29
|
-
if (nextStage === stage && opts?.isStopping) {
|
|
30
|
-
this.set(stage, 'completed');
|
|
38
|
+
// .stop() was called with a finalStatus
|
|
39
|
+
if (nextStage === stage && opts?.finalStatus) {
|
|
40
|
+
this.set(stage, opts.finalStatus);
|
|
31
41
|
this.stopMarker(stage);
|
|
32
42
|
continue;
|
|
33
43
|
}
|
|
@@ -40,12 +50,12 @@ export class StageTracker {
|
|
|
40
50
|
}
|
|
41
51
|
continue;
|
|
42
52
|
}
|
|
43
|
-
// any stage before the current stage should be marked
|
|
53
|
+
// any pending stage before the current stage should be marked using opts.bypassStatus
|
|
44
54
|
if (stages.indexOf(stage) < stages.indexOf(nextStage) && this.map.get(stage) === 'pending') {
|
|
45
|
-
this.set(stage, '
|
|
55
|
+
this.set(stage, opts?.bypassStatus ?? 'completed');
|
|
46
56
|
continue;
|
|
47
57
|
}
|
|
48
|
-
// any stage before the current stage should be as completed (if it hasn't been marked as skipped or failed yet)
|
|
58
|
+
// any stage before the current stage should be marked as completed (if it hasn't been marked as skipped or failed yet)
|
|
49
59
|
if (stages.indexOf(nextStage) > stages.indexOf(stage)) {
|
|
50
60
|
this.set(stage, 'completed');
|
|
51
61
|
this.stopMarker(stage);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oclif/multi-stage-output",
|
|
3
3
|
"description": "Terminal output for oclif commands with multiple stages",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1-dev.0",
|
|
5
5
|
"author": "Salesforce",
|
|
6
6
|
"bugs": "https://github.com/oclif/multi-stage-output/issues",
|
|
7
7
|
"dependencies": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"eslint-config-xo-react": "^0.27.0",
|
|
31
31
|
"eslint-plugin-react": "^7.34.3",
|
|
32
32
|
"eslint-plugin-react-hooks": "^4.6.2",
|
|
33
|
-
"husky": "^9.1.
|
|
33
|
+
"husky": "^9.1.5",
|
|
34
34
|
"ink-testing-library": "^4.0.0",
|
|
35
35
|
"lint-staged": "^15",
|
|
36
36
|
"mocha": "^10.7.3",
|