@pokit/reporter-web 0.0.1 → 0.0.2
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/package.json +7 -7
- package/src/adapter.ts +57 -0
- package/src/components/CommandBlock.tsx +94 -0
- package/src/components/ContentBox.tsx +32 -0
- package/src/components/FilePreview.tsx +99 -0
- package/src/components/ProgressIndicator.tsx +46 -0
- package/src/components/TutorialStep.tsx +45 -0
- package/src/components/index.ts +53 -0
- package/src/hooks.ts +242 -0
- package/src/store.ts +414 -0
- package/src/types.ts +141 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pokit/reporter-web",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Web/React event reporter for pok CLI applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -25,9 +25,8 @@
|
|
|
25
25
|
"bugs": {
|
|
26
26
|
"url": "https://github.com/notation-dev/openpok/issues"
|
|
27
27
|
},
|
|
28
|
-
"main": "./
|
|
29
|
-
"
|
|
30
|
-
"types": "./src/index.ts",
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
31
30
|
"exports": {
|
|
32
31
|
".": {
|
|
33
32
|
"bun": "./src/index.ts",
|
|
@@ -38,7 +37,8 @@
|
|
|
38
37
|
"files": [
|
|
39
38
|
"dist",
|
|
40
39
|
"README.md",
|
|
41
|
-
"LICENSE"
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"src"
|
|
42
42
|
],
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/bun": "latest",
|
|
48
48
|
"@types/react": "^18.2.0",
|
|
49
|
-
"@pokit/core": "0.0.
|
|
49
|
+
"@pokit/core": "0.0.2"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "^18.0.0 || ^19.0.0",
|
|
53
|
-
"@pokit/core": "0.0.
|
|
53
|
+
"@pokit/core": "0.0.2"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
56
|
"bun": ">=1.0.0"
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Reporter Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the ReporterAdapter interface for web/React environments.
|
|
5
|
+
* Connects the EventBus to a ReporterStore for state management.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ReporterAdapter, ReporterAdapterController, EventBus } from '@pokit/core';
|
|
9
|
+
import type { ReporterStoreWithHandler } from './store';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a web reporter adapter that pipes events to a store
|
|
13
|
+
*
|
|
14
|
+
* @param store - The reporter store (must be created with createReporterStore)
|
|
15
|
+
* @returns ReporterAdapter instance
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { createReporterStore, createWebReporterAdapter } from '@pokit/reporter-web';
|
|
20
|
+
* import { createEventBus } from '@pokit/core';
|
|
21
|
+
*
|
|
22
|
+
* const store = createReporterStore();
|
|
23
|
+
* const adapter = createWebReporterAdapter(store);
|
|
24
|
+
* const bus = createEventBus();
|
|
25
|
+
*
|
|
26
|
+
* const controller = adapter.start(bus);
|
|
27
|
+
*
|
|
28
|
+
* // Events emitted to the bus will update the store
|
|
29
|
+
* bus.emit({ type: 'root:start', appName: 'my-app' });
|
|
30
|
+
*
|
|
31
|
+
* // In React:
|
|
32
|
+
* // const state = useReporterState(store);
|
|
33
|
+
*
|
|
34
|
+
* // Cleanup
|
|
35
|
+
* controller.stop();
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function createWebReporterAdapter(store: ReporterStoreWithHandler): ReporterAdapter {
|
|
39
|
+
return {
|
|
40
|
+
start(bus: EventBus): ReporterAdapterController {
|
|
41
|
+
let stopped = false;
|
|
42
|
+
|
|
43
|
+
const unsubscribe = bus.on((event) => {
|
|
44
|
+
if (stopped) return;
|
|
45
|
+
store._handleEvent(event);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
stop(): void {
|
|
50
|
+
if (stopped) return;
|
|
51
|
+
stopped = true;
|
|
52
|
+
unsubscribe();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandBlock - A headless component for displaying shell commands with output.
|
|
3
|
+
*
|
|
4
|
+
* CSS Variables Contract:
|
|
5
|
+
* - --tutorial-code-bg: Background color for command/output area
|
|
6
|
+
* - --tutorial-bg: Default background color
|
|
7
|
+
* - --tutorial-text: Primary text color
|
|
8
|
+
* - --tutorial-text-muted: Secondary/muted text color
|
|
9
|
+
* - --tutorial-border: Border color
|
|
10
|
+
* - --tutorial-action-bg: Action button background
|
|
11
|
+
* - --tutorial-action-hover: Action button hover background
|
|
12
|
+
*
|
|
13
|
+
* Data Attributes:
|
|
14
|
+
* - [data-status="idle|running|complete|failed"]: Command status for styling
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type CommandBlockStatus = 'idle' | 'running' | 'complete' | 'failed';
|
|
18
|
+
|
|
19
|
+
export type CommandBlockActionProps = {
|
|
20
|
+
onClick: () => void;
|
|
21
|
+
status: CommandBlockStatus;
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CommandBlockProps = {
|
|
26
|
+
/** Command to display */
|
|
27
|
+
command: string;
|
|
28
|
+
/** Current status of the command execution */
|
|
29
|
+
status: CommandBlockStatus;
|
|
30
|
+
/** Output lines from the command */
|
|
31
|
+
output?: string[];
|
|
32
|
+
/** Callback when run action is triggered (if no renderAction provided) */
|
|
33
|
+
onRun?: () => void;
|
|
34
|
+
/** Render prop for custom action button */
|
|
35
|
+
renderAction?: (props: CommandBlockActionProps) => React.ReactNode;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function CommandBlock({
|
|
39
|
+
command,
|
|
40
|
+
status,
|
|
41
|
+
output,
|
|
42
|
+
onRun,
|
|
43
|
+
renderAction,
|
|
44
|
+
}: CommandBlockProps) {
|
|
45
|
+
const disabled = status === 'running';
|
|
46
|
+
const handleClick = () => {
|
|
47
|
+
if (!disabled && onRun) {
|
|
48
|
+
onRun();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const actionProps: CommandBlockActionProps = {
|
|
53
|
+
onClick: handleClick,
|
|
54
|
+
status,
|
|
55
|
+
disabled,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="command-block" data-status={status}>
|
|
60
|
+
<div className="command-block-command">
|
|
61
|
+
<span className="command-block-prompt">$</span>
|
|
62
|
+
<span className="command-block-text">{command}</span>
|
|
63
|
+
</div>
|
|
64
|
+
{output && output.length > 0 && (
|
|
65
|
+
<div className="command-block-output">
|
|
66
|
+
{output.map((line, index) => (
|
|
67
|
+
<div key={index} className="command-block-output-line">
|
|
68
|
+
{line}
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
{(renderAction || onRun) && (
|
|
74
|
+
<div className="command-block-actions">
|
|
75
|
+
{renderAction ? (
|
|
76
|
+
renderAction(actionProps)
|
|
77
|
+
) : (
|
|
78
|
+
<button
|
|
79
|
+
className="command-block-action-button"
|
|
80
|
+
onClick={handleClick}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
data-status={status}
|
|
83
|
+
>
|
|
84
|
+
{status === 'idle' && 'Run'}
|
|
85
|
+
{status === 'running' && 'Running...'}
|
|
86
|
+
{status === 'complete' && 'Run Again'}
|
|
87
|
+
{status === 'failed' && 'Retry'}
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentBox - A headless container component for info/tip/warning content.
|
|
3
|
+
*
|
|
4
|
+
* CSS Variables Contract:
|
|
5
|
+
* - --tutorial-bg: Default background color
|
|
6
|
+
* - --tutorial-text: Primary text color
|
|
7
|
+
* - --tutorial-text-muted: Secondary/muted text color
|
|
8
|
+
* - --tutorial-border: Border color
|
|
9
|
+
*
|
|
10
|
+
* Data Attributes:
|
|
11
|
+
* - [data-variant="info|tip|warning"]: Content variant for styling
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ContentBoxVariant = 'info' | 'tip' | 'warning';
|
|
15
|
+
|
|
16
|
+
export type ContentBoxProps = {
|
|
17
|
+
/** Visual variant of the content box */
|
|
18
|
+
variant: ContentBoxVariant;
|
|
19
|
+
/** Optional title for the content box */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Content to display inside the box */
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function ContentBox({ variant, title, children }: ContentBoxProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="content-box" data-variant={variant}>
|
|
28
|
+
{title && <div className="content-box-title">{title}</div>}
|
|
29
|
+
<div className="content-box-content">{children}</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilePreview - A headless component for displaying file content with action slot.
|
|
3
|
+
*
|
|
4
|
+
* CSS Variables Contract:
|
|
5
|
+
* - --tutorial-code-bg: Background color for code/file content area
|
|
6
|
+
* - --tutorial-bg: Default background color
|
|
7
|
+
* - --tutorial-text: Primary text color
|
|
8
|
+
* - --tutorial-text-muted: Secondary/muted text color
|
|
9
|
+
* - --tutorial-border: Border color
|
|
10
|
+
* - --tutorial-action-bg: Action button background
|
|
11
|
+
* - --tutorial-action-hover: Action button hover background
|
|
12
|
+
*
|
|
13
|
+
* Data Attributes:
|
|
14
|
+
* - [data-status="pending|creating|created"]: File status for styling
|
|
15
|
+
* - [data-language="..."]: Language hint for syntax highlighting
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type FilePreviewStatus = 'pending' | 'creating' | 'created';
|
|
19
|
+
|
|
20
|
+
export type FilePreviewActionProps = {
|
|
21
|
+
onClick: () => void;
|
|
22
|
+
status: FilePreviewStatus;
|
|
23
|
+
disabled: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type FilePreviewProps = {
|
|
27
|
+
/** File path to display in header */
|
|
28
|
+
path: string;
|
|
29
|
+
/** File content to display */
|
|
30
|
+
content: string;
|
|
31
|
+
/** Language for syntax highlighting hint */
|
|
32
|
+
language?: string;
|
|
33
|
+
/** Current status of the file operation */
|
|
34
|
+
status: FilePreviewStatus;
|
|
35
|
+
/** Callback when action is triggered (if no renderAction provided) */
|
|
36
|
+
onAction?: () => void;
|
|
37
|
+
/** Render prop for custom action button */
|
|
38
|
+
renderAction?: (props: FilePreviewActionProps) => React.ReactNode;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function FilePreview({
|
|
42
|
+
path,
|
|
43
|
+
content,
|
|
44
|
+
language,
|
|
45
|
+
status,
|
|
46
|
+
onAction,
|
|
47
|
+
renderAction,
|
|
48
|
+
}: FilePreviewProps) {
|
|
49
|
+
const disabled = status === 'creating' || status === 'created';
|
|
50
|
+
const handleClick = () => {
|
|
51
|
+
if (!disabled && onAction) {
|
|
52
|
+
onAction();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const actionProps: FilePreviewActionProps = {
|
|
57
|
+
onClick: handleClick,
|
|
58
|
+
status,
|
|
59
|
+
disabled,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
className="file-preview"
|
|
65
|
+
data-status={status}
|
|
66
|
+
data-language={language}
|
|
67
|
+
>
|
|
68
|
+
<div className="file-preview-header">
|
|
69
|
+
<span className="file-preview-path">{path}</span>
|
|
70
|
+
{language && (
|
|
71
|
+
<span className="file-preview-language">{language}</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
<div className="file-preview-content">
|
|
75
|
+
<pre className="file-preview-code">
|
|
76
|
+
<code>{content}</code>
|
|
77
|
+
</pre>
|
|
78
|
+
</div>
|
|
79
|
+
{(renderAction || onAction) && (
|
|
80
|
+
<div className="file-preview-actions">
|
|
81
|
+
{renderAction ? (
|
|
82
|
+
renderAction(actionProps)
|
|
83
|
+
) : (
|
|
84
|
+
<button
|
|
85
|
+
className="file-preview-action-button"
|
|
86
|
+
onClick={handleClick}
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
data-status={status}
|
|
89
|
+
>
|
|
90
|
+
{status === 'pending' && 'Create'}
|
|
91
|
+
{status === 'creating' && 'Creating...'}
|
|
92
|
+
{status === 'created' && 'Created'}
|
|
93
|
+
</button>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgressIndicator - A headless component for displaying tutorial progress.
|
|
3
|
+
*
|
|
4
|
+
* CSS Variables Contract:
|
|
5
|
+
* - --tutorial-text: Primary text color
|
|
6
|
+
* - --tutorial-text-muted: Secondary/muted text color
|
|
7
|
+
*
|
|
8
|
+
* Data Attributes:
|
|
9
|
+
* - [data-progress]: Current progress ratio (0-1) as data attribute
|
|
10
|
+
* - [data-complete="true"]: When current equals total
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ProgressIndicatorProps = {
|
|
14
|
+
/** Current step number (1-indexed) */
|
|
15
|
+
current: number;
|
|
16
|
+
/** Total number of steps */
|
|
17
|
+
total: number;
|
|
18
|
+
/** Optional custom label (defaults to "Step X of Y") */
|
|
19
|
+
label?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function ProgressIndicator({
|
|
23
|
+
current,
|
|
24
|
+
total,
|
|
25
|
+
label,
|
|
26
|
+
}: ProgressIndicatorProps) {
|
|
27
|
+
const progress = total > 0 ? current / total : 0;
|
|
28
|
+
const isComplete = current >= total;
|
|
29
|
+
const displayLabel = label ?? `Step ${current} of ${total}`;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className="progress-indicator"
|
|
34
|
+
data-progress={progress.toFixed(2)}
|
|
35
|
+
data-complete={isComplete ? 'true' : undefined}
|
|
36
|
+
>
|
|
37
|
+
<span className="progress-indicator-label">{displayLabel}</span>
|
|
38
|
+
<div className="progress-indicator-bar">
|
|
39
|
+
<div
|
|
40
|
+
className="progress-indicator-fill"
|
|
41
|
+
style={{ width: `${progress * 100}%` }}
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TutorialStep - A headless component for rendering tutorial step containers.
|
|
3
|
+
*
|
|
4
|
+
* CSS Variables Contract:
|
|
5
|
+
* - --tutorial-step-active: Background/border color for active step
|
|
6
|
+
* - --tutorial-step-complete: Background/border color for complete step
|
|
7
|
+
* - --tutorial-step-pending: Background/border color for pending step
|
|
8
|
+
* - --tutorial-bg: Default background color
|
|
9
|
+
* - --tutorial-text: Primary text color
|
|
10
|
+
* - --tutorial-text-muted: Secondary/muted text color
|
|
11
|
+
* - --tutorial-border: Border color
|
|
12
|
+
*
|
|
13
|
+
* Data Attributes:
|
|
14
|
+
* - [data-status="pending|active|complete"]: Step status for styling
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type TutorialStepStatus = 'pending' | 'active' | 'complete';
|
|
18
|
+
|
|
19
|
+
export type TutorialStepProps = {
|
|
20
|
+
/** Step number displayed in the header */
|
|
21
|
+
number: number;
|
|
22
|
+
/** Step title */
|
|
23
|
+
title: string;
|
|
24
|
+
/** Current status of the step */
|
|
25
|
+
status: TutorialStepStatus;
|
|
26
|
+
/** Step content */
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function TutorialStep({
|
|
31
|
+
number,
|
|
32
|
+
title,
|
|
33
|
+
status,
|
|
34
|
+
children,
|
|
35
|
+
}: TutorialStepProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="tutorial-step" data-status={status}>
|
|
38
|
+
<div className="tutorial-step-header">
|
|
39
|
+
<span className="tutorial-step-number">{number}</span>
|
|
40
|
+
<span className="tutorial-step-title">{title}</span>
|
|
41
|
+
</div>
|
|
42
|
+
<div className="tutorial-step-content">{children}</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tutorial Renderer Components
|
|
3
|
+
*
|
|
4
|
+
* Headless React components for rendering tutorial content.
|
|
5
|
+
* All components use CSS variables and data attributes for styling,
|
|
6
|
+
* allowing full customization by the consuming application.
|
|
7
|
+
*
|
|
8
|
+
* CSS Variables Contract:
|
|
9
|
+
* --tutorial-bg Default background color
|
|
10
|
+
* --tutorial-step-active Background/border for active step
|
|
11
|
+
* --tutorial-step-complete Background/border for complete step
|
|
12
|
+
* --tutorial-step-pending Background/border for pending step
|
|
13
|
+
* --tutorial-code-bg Background for code/command blocks
|
|
14
|
+
* --tutorial-action-bg Action button background
|
|
15
|
+
* --tutorial-action-hover Action button hover background
|
|
16
|
+
* --tutorial-border Border color
|
|
17
|
+
* --tutorial-text Primary text color
|
|
18
|
+
* --tutorial-text-muted Secondary/muted text color
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// TutorialStep
|
|
22
|
+
export { TutorialStep } from './TutorialStep';
|
|
23
|
+
export type {
|
|
24
|
+
TutorialStepProps,
|
|
25
|
+
TutorialStepStatus,
|
|
26
|
+
} from './TutorialStep';
|
|
27
|
+
|
|
28
|
+
// FilePreview
|
|
29
|
+
export { FilePreview } from './FilePreview';
|
|
30
|
+
export type {
|
|
31
|
+
FilePreviewProps,
|
|
32
|
+
FilePreviewStatus,
|
|
33
|
+
FilePreviewActionProps,
|
|
34
|
+
} from './FilePreview';
|
|
35
|
+
|
|
36
|
+
// CommandBlock
|
|
37
|
+
export { CommandBlock } from './CommandBlock';
|
|
38
|
+
export type {
|
|
39
|
+
CommandBlockProps,
|
|
40
|
+
CommandBlockStatus,
|
|
41
|
+
CommandBlockActionProps,
|
|
42
|
+
} from './CommandBlock';
|
|
43
|
+
|
|
44
|
+
// ProgressIndicator
|
|
45
|
+
export { ProgressIndicator } from './ProgressIndicator';
|
|
46
|
+
export type { ProgressIndicatorProps } from './ProgressIndicator';
|
|
47
|
+
|
|
48
|
+
// ContentBox
|
|
49
|
+
export { ContentBox } from './ContentBox';
|
|
50
|
+
export type {
|
|
51
|
+
ContentBoxProps,
|
|
52
|
+
ContentBoxVariant,
|
|
53
|
+
} from './ContentBox';
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporter Web React Hooks
|
|
3
|
+
*
|
|
4
|
+
* React hooks for subscribing to reporter state using useSyncExternalStore.
|
|
5
|
+
* Provides full state subscription and selective subscriptions for individual
|
|
6
|
+
* activities and groups.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useSyncExternalStore, useCallback, useRef } from 'react';
|
|
10
|
+
import type { ActivityId, GroupId } from '@pokit/core';
|
|
11
|
+
import type { ReporterStore, ReporterState, ActivityState, GroupState } from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Subscribe to the full reporter state
|
|
15
|
+
*
|
|
16
|
+
* @param store - The reporter store
|
|
17
|
+
* @returns Current reporter state
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const state = useReporterState(store);
|
|
22
|
+
* return <div>Status: {state.root.status}</div>;
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function useReporterState(store: ReporterStore): ReporterState {
|
|
26
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Subscribe to a single activity by ID
|
|
31
|
+
* Returns undefined if activity doesn't exist
|
|
32
|
+
*
|
|
33
|
+
* @param store - The reporter store
|
|
34
|
+
* @param id - Activity ID to subscribe to
|
|
35
|
+
* @returns Activity state or undefined
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const activity = useActivity(store, 'task-1');
|
|
40
|
+
* if (!activity) return null;
|
|
41
|
+
* return <div>{activity.label}: {activity.status}</div>;
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function useActivity(store: ReporterStore, id: ActivityId): ActivityState | undefined {
|
|
45
|
+
// Track the previous activity reference for shallow comparison
|
|
46
|
+
const prevActivityRef = useRef<ActivityState | undefined>(undefined);
|
|
47
|
+
|
|
48
|
+
const getSnapshot = useCallback(() => {
|
|
49
|
+
const state = store.getSnapshot();
|
|
50
|
+
const activity = state.activities.get(id);
|
|
51
|
+
|
|
52
|
+
// Return same reference if activity hasn't changed (shallow comparison)
|
|
53
|
+
if (prevActivityRef.current === activity) {
|
|
54
|
+
return prevActivityRef.current;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if activity content is the same (for Map recreation scenarios)
|
|
58
|
+
if (
|
|
59
|
+
prevActivityRef.current &&
|
|
60
|
+
activity &&
|
|
61
|
+
prevActivityRef.current.id === activity.id &&
|
|
62
|
+
prevActivityRef.current.status === activity.status &&
|
|
63
|
+
prevActivityRef.current.progress === activity.progress &&
|
|
64
|
+
prevActivityRef.current.message === activity.message &&
|
|
65
|
+
prevActivityRef.current.justStarted === activity.justStarted &&
|
|
66
|
+
prevActivityRef.current.justCompleted === activity.justCompleted &&
|
|
67
|
+
prevActivityRef.current.justFailed === activity.justFailed &&
|
|
68
|
+
prevActivityRef.current.completedAt === activity.completedAt
|
|
69
|
+
) {
|
|
70
|
+
return prevActivityRef.current;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
prevActivityRef.current = activity;
|
|
74
|
+
return activity;
|
|
75
|
+
}, [store, id]);
|
|
76
|
+
|
|
77
|
+
const getServerSnapshot = useCallback(() => {
|
|
78
|
+
return store.getServerSnapshot().activities.get(id);
|
|
79
|
+
}, [store, id]);
|
|
80
|
+
|
|
81
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Subscribe to a single group by ID
|
|
86
|
+
* Returns undefined if group doesn't exist
|
|
87
|
+
*
|
|
88
|
+
* @param store - The reporter store
|
|
89
|
+
* @param id - Group ID to subscribe to
|
|
90
|
+
* @returns Group state or undefined
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```tsx
|
|
94
|
+
* const group = useGroup(store, 'checks');
|
|
95
|
+
* if (!group) return null;
|
|
96
|
+
* return <div>{group.label} ({group.activityIds.length} tasks)</div>;
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function useGroup(store: ReporterStore, id: GroupId): GroupState | undefined {
|
|
100
|
+
// Track the previous group reference for shallow comparison
|
|
101
|
+
const prevGroupRef = useRef<GroupState | undefined>(undefined);
|
|
102
|
+
|
|
103
|
+
const getSnapshot = useCallback(() => {
|
|
104
|
+
const state = store.getSnapshot();
|
|
105
|
+
const group = state.groups.get(id);
|
|
106
|
+
|
|
107
|
+
// Return same reference if group hasn't changed
|
|
108
|
+
if (prevGroupRef.current === group) {
|
|
109
|
+
return prevGroupRef.current;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if group content is the same (for Map recreation scenarios)
|
|
113
|
+
if (
|
|
114
|
+
prevGroupRef.current &&
|
|
115
|
+
group &&
|
|
116
|
+
prevGroupRef.current.id === group.id &&
|
|
117
|
+
prevGroupRef.current.label === group.label &&
|
|
118
|
+
prevGroupRef.current.hasFailure === group.hasFailure &&
|
|
119
|
+
prevGroupRef.current.justStarted_group === group.justStarted_group &&
|
|
120
|
+
prevGroupRef.current.justEnded === group.justEnded &&
|
|
121
|
+
prevGroupRef.current.endedAt === group.endedAt &&
|
|
122
|
+
prevGroupRef.current.activityIds.length === group.activityIds.length &&
|
|
123
|
+
prevGroupRef.current.childGroupIds.length === group.childGroupIds.length
|
|
124
|
+
) {
|
|
125
|
+
return prevGroupRef.current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
prevGroupRef.current = group;
|
|
129
|
+
return group;
|
|
130
|
+
}, [store, id]);
|
|
131
|
+
|
|
132
|
+
const getServerSnapshot = useCallback(() => {
|
|
133
|
+
return store.getServerSnapshot().groups.get(id);
|
|
134
|
+
}, [store, id]);
|
|
135
|
+
|
|
136
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Subscribe to root state only
|
|
141
|
+
* More efficient than useReporterState when you only need root info
|
|
142
|
+
*
|
|
143
|
+
* @param store - The reporter store
|
|
144
|
+
* @returns Root state
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```tsx
|
|
148
|
+
* const root = useRootState(store);
|
|
149
|
+
* return <div>App: {root.appName} - {root.status}</div>;
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function useRootState(store: ReporterStore): ReporterState['root'] {
|
|
153
|
+
const prevRootRef = useRef<ReporterState['root'] | undefined>(undefined);
|
|
154
|
+
|
|
155
|
+
const getSnapshot = useCallback(() => {
|
|
156
|
+
const state = store.getSnapshot();
|
|
157
|
+
const root = state.root;
|
|
158
|
+
|
|
159
|
+
// Return same reference if root hasn't changed
|
|
160
|
+
if (
|
|
161
|
+
prevRootRef.current &&
|
|
162
|
+
prevRootRef.current.status === root.status &&
|
|
163
|
+
prevRootRef.current.appName === root.appName &&
|
|
164
|
+
prevRootRef.current.exitCode === root.exitCode
|
|
165
|
+
) {
|
|
166
|
+
return prevRootRef.current;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
prevRootRef.current = root;
|
|
170
|
+
return root;
|
|
171
|
+
}, [store]);
|
|
172
|
+
|
|
173
|
+
const getServerSnapshot = useCallback(() => {
|
|
174
|
+
return store.getServerSnapshot().root;
|
|
175
|
+
}, [store]);
|
|
176
|
+
|
|
177
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Subscribe to logs
|
|
182
|
+
* Returns the full log array
|
|
183
|
+
*
|
|
184
|
+
* @param store - The reporter store
|
|
185
|
+
* @returns Array of log entries
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```tsx
|
|
189
|
+
* const logs = useLogs(store);
|
|
190
|
+
* return (
|
|
191
|
+
* <ul>
|
|
192
|
+
* {logs.map(log => <li key={log.id}>{log.message}</li>)}
|
|
193
|
+
* </ul>
|
|
194
|
+
* );
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
export function useLogs(store: ReporterStore): ReporterState['logs'] {
|
|
198
|
+
const prevLogsRef = useRef<ReporterState['logs'] | undefined>(undefined);
|
|
199
|
+
|
|
200
|
+
const getSnapshot = useCallback(() => {
|
|
201
|
+
const state = store.getSnapshot();
|
|
202
|
+
const logs = state.logs;
|
|
203
|
+
|
|
204
|
+
// Return same reference if logs haven't changed
|
|
205
|
+
if (prevLogsRef.current && prevLogsRef.current.length === logs.length) {
|
|
206
|
+
// Quick check - if lengths match and last item is same, assume unchanged
|
|
207
|
+
if (
|
|
208
|
+
logs.length === 0 ||
|
|
209
|
+
prevLogsRef.current[logs.length - 1]?.id === logs[logs.length - 1]?.id
|
|
210
|
+
) {
|
|
211
|
+
return prevLogsRef.current;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
prevLogsRef.current = logs;
|
|
216
|
+
return logs;
|
|
217
|
+
}, [store]);
|
|
218
|
+
|
|
219
|
+
const getServerSnapshot = useCallback(() => {
|
|
220
|
+
return store.getServerSnapshot().logs;
|
|
221
|
+
}, [store]);
|
|
222
|
+
|
|
223
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Subscribe to suspended state
|
|
228
|
+
*
|
|
229
|
+
* @param store - The reporter store
|
|
230
|
+
* @returns Whether reporter is suspended
|
|
231
|
+
*/
|
|
232
|
+
export function useSuspended(store: ReporterStore): boolean {
|
|
233
|
+
const getSnapshot = useCallback(() => {
|
|
234
|
+
return store.getSnapshot().suspended;
|
|
235
|
+
}, [store]);
|
|
236
|
+
|
|
237
|
+
const getServerSnapshot = useCallback(() => {
|
|
238
|
+
return store.getServerSnapshot().suspended;
|
|
239
|
+
}, [store]);
|
|
240
|
+
|
|
241
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
|
|
242
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporter Web Store
|
|
3
|
+
*
|
|
4
|
+
* Creates an external store for React integration.
|
|
5
|
+
* Handles all CLIEvent types and maintains normalized state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CLIEvent, ActivityId, GroupId } from '@pokit/core';
|
|
9
|
+
import type {
|
|
10
|
+
ReporterState,
|
|
11
|
+
ReporterStore,
|
|
12
|
+
StateListener,
|
|
13
|
+
ActivityState,
|
|
14
|
+
GroupState,
|
|
15
|
+
LogEntry,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
/** Default delay for clearing temporal markers (ms) */
|
|
19
|
+
const TEMPORAL_MARKER_DELAY = 600;
|
|
20
|
+
|
|
21
|
+
/** Counter for generating unique log IDs */
|
|
22
|
+
let logIdCounter = 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create initial reporter state
|
|
26
|
+
*/
|
|
27
|
+
function createInitialState(): ReporterState {
|
|
28
|
+
return {
|
|
29
|
+
root: {
|
|
30
|
+
status: 'idle',
|
|
31
|
+
},
|
|
32
|
+
groups: new Map(),
|
|
33
|
+
activities: new Map(),
|
|
34
|
+
logs: [],
|
|
35
|
+
suspended: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Options for creating a reporter store
|
|
41
|
+
*/
|
|
42
|
+
export type CreateReporterStoreOptions = {
|
|
43
|
+
/** Custom delay for clearing temporal markers (default: 600ms) */
|
|
44
|
+
temporalMarkerDelay?: number;
|
|
45
|
+
/** Disable temporal marker auto-clearing (useful for testing) */
|
|
46
|
+
disableTemporalMarkerClearing?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Type for the store with internal event handler exposed
|
|
51
|
+
*/
|
|
52
|
+
export type ReporterStoreWithHandler = ReporterStore & {
|
|
53
|
+
_handleEvent: (event: CLIEvent) => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a reporter store for React integration
|
|
58
|
+
*
|
|
59
|
+
* @param options - Optional configuration
|
|
60
|
+
* @returns Store compatible with useSyncExternalStore
|
|
61
|
+
*/
|
|
62
|
+
export function createReporterStore(options?: CreateReporterStoreOptions): ReporterStoreWithHandler {
|
|
63
|
+
const temporalMarkerDelay = options?.temporalMarkerDelay ?? TEMPORAL_MARKER_DELAY;
|
|
64
|
+
const disableTemporalMarkerClearing = options?.disableTemporalMarkerClearing ?? false;
|
|
65
|
+
|
|
66
|
+
let state = createInitialState();
|
|
67
|
+
const listeners = new Set<StateListener>();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Notify all listeners of state change
|
|
71
|
+
*/
|
|
72
|
+
function notifyListeners(): void {
|
|
73
|
+
for (const listener of listeners) {
|
|
74
|
+
listener();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Schedule clearing of temporal markers for an activity
|
|
80
|
+
*/
|
|
81
|
+
function scheduleActivityMarkerClear(activityId: ActivityId, markers: (keyof ActivityState)[]): void {
|
|
82
|
+
if (disableTemporalMarkerClearing) return;
|
|
83
|
+
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
const activity = state.activities.get(activityId);
|
|
86
|
+
if (!activity) return;
|
|
87
|
+
|
|
88
|
+
// Check if any markers are still set
|
|
89
|
+
const hasMarkers = markers.some((marker) => activity[marker]);
|
|
90
|
+
if (!hasMarkers) return;
|
|
91
|
+
|
|
92
|
+
// Create new state with cleared markers
|
|
93
|
+
const updatedActivity = { ...activity };
|
|
94
|
+
for (const marker of markers) {
|
|
95
|
+
delete updatedActivity[marker];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const newActivities = new Map(state.activities);
|
|
99
|
+
newActivities.set(activityId, updatedActivity);
|
|
100
|
+
|
|
101
|
+
state = {
|
|
102
|
+
...state,
|
|
103
|
+
activities: newActivities,
|
|
104
|
+
};
|
|
105
|
+
notifyListeners();
|
|
106
|
+
}, temporalMarkerDelay);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Schedule clearing of temporal markers for a group
|
|
111
|
+
*/
|
|
112
|
+
function scheduleGroupMarkerClear(groupId: GroupId, markers: (keyof GroupState)[]): void {
|
|
113
|
+
if (disableTemporalMarkerClearing) return;
|
|
114
|
+
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
const group = state.groups.get(groupId);
|
|
117
|
+
if (!group) return;
|
|
118
|
+
|
|
119
|
+
// Check if any markers are still set
|
|
120
|
+
const hasMarkers = markers.some((marker) => group[marker]);
|
|
121
|
+
if (!hasMarkers) return;
|
|
122
|
+
|
|
123
|
+
// Create new state with cleared markers
|
|
124
|
+
const updatedGroup = { ...group };
|
|
125
|
+
for (const marker of markers) {
|
|
126
|
+
delete updatedGroup[marker];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const newGroups = new Map(state.groups);
|
|
130
|
+
newGroups.set(groupId, updatedGroup);
|
|
131
|
+
|
|
132
|
+
state = {
|
|
133
|
+
...state,
|
|
134
|
+
groups: newGroups,
|
|
135
|
+
};
|
|
136
|
+
notifyListeners();
|
|
137
|
+
}, temporalMarkerDelay);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Handle a CLI event and update state
|
|
142
|
+
*/
|
|
143
|
+
function handleEvent(event: CLIEvent): void {
|
|
144
|
+
switch (event.type) {
|
|
145
|
+
case 'root:start': {
|
|
146
|
+
state = {
|
|
147
|
+
...state,
|
|
148
|
+
root: {
|
|
149
|
+
status: 'running',
|
|
150
|
+
appName: event.appName,
|
|
151
|
+
version: event.version,
|
|
152
|
+
startedAt: Date.now(),
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
notifyListeners();
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'root:end': {
|
|
160
|
+
state = {
|
|
161
|
+
...state,
|
|
162
|
+
root: {
|
|
163
|
+
...state.root,
|
|
164
|
+
status: event.exitCode === 0 ? 'complete' : 'error',
|
|
165
|
+
exitCode: event.exitCode,
|
|
166
|
+
endedAt: Date.now(),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
notifyListeners();
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case 'group:start': {
|
|
174
|
+
const newGroup: GroupState = {
|
|
175
|
+
id: event.id,
|
|
176
|
+
parentId: event.parentId,
|
|
177
|
+
label: event.label,
|
|
178
|
+
layout: event.layout,
|
|
179
|
+
activityIds: [],
|
|
180
|
+
childGroupIds: [],
|
|
181
|
+
hasFailure: false,
|
|
182
|
+
startedAt: Date.now(),
|
|
183
|
+
justStarted_group: true,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const newGroups = new Map(state.groups);
|
|
187
|
+
newGroups.set(event.id, newGroup);
|
|
188
|
+
|
|
189
|
+
// Add to parent's childGroupIds if parent exists
|
|
190
|
+
if (event.parentId) {
|
|
191
|
+
const parent = state.groups.get(event.parentId);
|
|
192
|
+
if (parent) {
|
|
193
|
+
newGroups.set(event.parentId, {
|
|
194
|
+
...parent,
|
|
195
|
+
childGroupIds: [...parent.childGroupIds, event.id],
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
state = {
|
|
201
|
+
...state,
|
|
202
|
+
groups: newGroups,
|
|
203
|
+
};
|
|
204
|
+
notifyListeners();
|
|
205
|
+
scheduleGroupMarkerClear(event.id, ['justStarted_group']);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case 'group:end': {
|
|
210
|
+
const group = state.groups.get(event.id);
|
|
211
|
+
if (!group) break;
|
|
212
|
+
|
|
213
|
+
const newGroups = new Map(state.groups);
|
|
214
|
+
newGroups.set(event.id, {
|
|
215
|
+
...group,
|
|
216
|
+
endedAt: Date.now(),
|
|
217
|
+
justEnded: true,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
state = {
|
|
221
|
+
...state,
|
|
222
|
+
groups: newGroups,
|
|
223
|
+
};
|
|
224
|
+
notifyListeners();
|
|
225
|
+
scheduleGroupMarkerClear(event.id, ['justEnded']);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'activity:start': {
|
|
230
|
+
const newActivity: ActivityState = {
|
|
231
|
+
id: event.id,
|
|
232
|
+
parentId: event.parentId,
|
|
233
|
+
label: event.label,
|
|
234
|
+
status: 'running',
|
|
235
|
+
meta: event.meta,
|
|
236
|
+
startedAt: Date.now(),
|
|
237
|
+
justStarted: true,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const newActivities = new Map(state.activities);
|
|
241
|
+
newActivities.set(event.id, newActivity);
|
|
242
|
+
|
|
243
|
+
// Add to parent group's activityIds if parent is a group
|
|
244
|
+
const newGroups = new Map(state.groups);
|
|
245
|
+
if (event.parentId) {
|
|
246
|
+
const parentGroup = state.groups.get(event.parentId as GroupId);
|
|
247
|
+
if (parentGroup) {
|
|
248
|
+
newGroups.set(event.parentId as GroupId, {
|
|
249
|
+
...parentGroup,
|
|
250
|
+
activityIds: [...parentGroup.activityIds, event.id],
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
state = {
|
|
256
|
+
...state,
|
|
257
|
+
activities: newActivities,
|
|
258
|
+
groups: newGroups,
|
|
259
|
+
};
|
|
260
|
+
notifyListeners();
|
|
261
|
+
scheduleActivityMarkerClear(event.id, ['justStarted']);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case 'activity:update': {
|
|
266
|
+
const activity = state.activities.get(event.id);
|
|
267
|
+
if (!activity) break;
|
|
268
|
+
|
|
269
|
+
const { progress, message, ...rest } = event.payload;
|
|
270
|
+
|
|
271
|
+
const newActivities = new Map(state.activities);
|
|
272
|
+
newActivities.set(event.id, {
|
|
273
|
+
...activity,
|
|
274
|
+
progress: progress ?? activity.progress,
|
|
275
|
+
message: message ?? activity.message,
|
|
276
|
+
payload: {
|
|
277
|
+
...activity.payload,
|
|
278
|
+
...rest,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
state = {
|
|
283
|
+
...state,
|
|
284
|
+
activities: newActivities,
|
|
285
|
+
};
|
|
286
|
+
notifyListeners();
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case 'activity:success': {
|
|
291
|
+
const activity = state.activities.get(event.id);
|
|
292
|
+
if (!activity) break;
|
|
293
|
+
|
|
294
|
+
const newActivities = new Map(state.activities);
|
|
295
|
+
newActivities.set(event.id, {
|
|
296
|
+
...activity,
|
|
297
|
+
status: 'success',
|
|
298
|
+
completedAt: Date.now(),
|
|
299
|
+
justCompleted: true,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
state = {
|
|
303
|
+
...state,
|
|
304
|
+
activities: newActivities,
|
|
305
|
+
};
|
|
306
|
+
notifyListeners();
|
|
307
|
+
scheduleActivityMarkerClear(event.id, ['justCompleted']);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case 'activity:failure': {
|
|
312
|
+
const activity = state.activities.get(event.id);
|
|
313
|
+
if (!activity) break;
|
|
314
|
+
|
|
315
|
+
const errorMessage = event.error instanceof Error ? event.error.message : String(event.error);
|
|
316
|
+
|
|
317
|
+
const newActivities = new Map(state.activities);
|
|
318
|
+
newActivities.set(event.id, {
|
|
319
|
+
...activity,
|
|
320
|
+
status: 'failure',
|
|
321
|
+
completedAt: Date.now(),
|
|
322
|
+
justFailed: true,
|
|
323
|
+
error: {
|
|
324
|
+
message: errorMessage,
|
|
325
|
+
remediation: event.remediation,
|
|
326
|
+
documentationUrl: event.documentationUrl,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Mark parent group as having failure
|
|
331
|
+
const newGroups = new Map(state.groups);
|
|
332
|
+
if (activity.parentId) {
|
|
333
|
+
const parentGroup = state.groups.get(activity.parentId as GroupId);
|
|
334
|
+
if (parentGroup) {
|
|
335
|
+
newGroups.set(activity.parentId as GroupId, {
|
|
336
|
+
...parentGroup,
|
|
337
|
+
hasFailure: true,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
state = {
|
|
343
|
+
...state,
|
|
344
|
+
activities: newActivities,
|
|
345
|
+
groups: newGroups,
|
|
346
|
+
};
|
|
347
|
+
notifyListeners();
|
|
348
|
+
scheduleActivityMarkerClear(event.id, ['justFailed']);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'log': {
|
|
353
|
+
const logEntry: LogEntry = {
|
|
354
|
+
id: `log-${++logIdCounter}`,
|
|
355
|
+
activityId: event.activityId,
|
|
356
|
+
level: event.level,
|
|
357
|
+
message: event.message,
|
|
358
|
+
timestamp: Date.now(),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
state = {
|
|
362
|
+
...state,
|
|
363
|
+
logs: [...state.logs, logEntry],
|
|
364
|
+
};
|
|
365
|
+
notifyListeners();
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case 'reporter:suspend': {
|
|
370
|
+
state = {
|
|
371
|
+
...state,
|
|
372
|
+
suspended: true,
|
|
373
|
+
};
|
|
374
|
+
notifyListeners();
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
case 'reporter:resume': {
|
|
379
|
+
state = {
|
|
380
|
+
...state,
|
|
381
|
+
suspended: false,
|
|
382
|
+
};
|
|
383
|
+
notifyListeners();
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
getState(): ReporterState {
|
|
391
|
+
return state;
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
getSnapshot(): ReporterState {
|
|
395
|
+
return state;
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
getServerSnapshot(): ReporterState {
|
|
399
|
+
return state;
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
subscribe(listener: StateListener): () => void {
|
|
403
|
+
listeners.add(listener);
|
|
404
|
+
return () => {
|
|
405
|
+
listeners.delete(listener);
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Internal method to handle events - exposed for adapter use
|
|
411
|
+
*/
|
|
412
|
+
_handleEvent: handleEvent,
|
|
413
|
+
};
|
|
414
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporter Web Types
|
|
3
|
+
*
|
|
4
|
+
* State shape definitions for the web reporter store.
|
|
5
|
+
* Designed for React integration via useSyncExternalStore.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ActivityId, GroupId, GroupLayout, LogLevel } from '@pokit/core';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Root lifecycle status
|
|
12
|
+
*/
|
|
13
|
+
export type RootStatus = 'idle' | 'running' | 'complete' | 'error';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Activity status
|
|
17
|
+
*/
|
|
18
|
+
export type ActivityStatus = 'pending' | 'running' | 'success' | 'failure';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Temporal markers for animation hints
|
|
22
|
+
* These auto-clear after a short delay (600ms) to enable smooth UI transitions
|
|
23
|
+
*/
|
|
24
|
+
export type TemporalMarkers = {
|
|
25
|
+
/** Activity just started (for entrance animations) */
|
|
26
|
+
justStarted?: boolean;
|
|
27
|
+
/** Activity just completed successfully (for success animations) */
|
|
28
|
+
justCompleted?: boolean;
|
|
29
|
+
/** Activity just failed (for error animations) */
|
|
30
|
+
justFailed?: boolean;
|
|
31
|
+
/** Group just started */
|
|
32
|
+
justStarted_group?: boolean;
|
|
33
|
+
/** Group just ended */
|
|
34
|
+
justEnded?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Activity state - represents a unit of work
|
|
39
|
+
*/
|
|
40
|
+
export type ActivityState = TemporalMarkers & {
|
|
41
|
+
id: ActivityId;
|
|
42
|
+
parentId?: GroupId | ActivityId;
|
|
43
|
+
label: string;
|
|
44
|
+
status: ActivityStatus;
|
|
45
|
+
/** Progress 0-100 */
|
|
46
|
+
progress?: number;
|
|
47
|
+
/** Current status message */
|
|
48
|
+
message?: string;
|
|
49
|
+
/** Custom metadata */
|
|
50
|
+
meta?: Record<string, unknown>;
|
|
51
|
+
/** Error information if failed */
|
|
52
|
+
error?: {
|
|
53
|
+
message: string;
|
|
54
|
+
remediation?: string[];
|
|
55
|
+
documentationUrl?: string;
|
|
56
|
+
};
|
|
57
|
+
/** Custom payload data from updates */
|
|
58
|
+
payload?: Record<string, unknown>;
|
|
59
|
+
/** Timestamp when started */
|
|
60
|
+
startedAt: number;
|
|
61
|
+
/** Timestamp when completed (success or failure) */
|
|
62
|
+
completedAt?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Group state - represents a container for activities
|
|
67
|
+
*/
|
|
68
|
+
export type GroupState = TemporalMarkers & {
|
|
69
|
+
id: GroupId;
|
|
70
|
+
parentId?: GroupId;
|
|
71
|
+
label: string;
|
|
72
|
+
layout: GroupLayout;
|
|
73
|
+
/** Child activity IDs in order */
|
|
74
|
+
activityIds: ActivityId[];
|
|
75
|
+
/** Child group IDs in order */
|
|
76
|
+
childGroupIds: GroupId[];
|
|
77
|
+
/** Whether any child has failed */
|
|
78
|
+
hasFailure: boolean;
|
|
79
|
+
/** Timestamp when started */
|
|
80
|
+
startedAt: number;
|
|
81
|
+
/** Timestamp when ended */
|
|
82
|
+
endedAt?: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Log entry
|
|
87
|
+
*/
|
|
88
|
+
export type LogEntry = {
|
|
89
|
+
id: string;
|
|
90
|
+
activityId?: ActivityId;
|
|
91
|
+
level: LogLevel;
|
|
92
|
+
message: string;
|
|
93
|
+
timestamp: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Root state for the reporter
|
|
98
|
+
*/
|
|
99
|
+
export type RootState = {
|
|
100
|
+
appName?: string;
|
|
101
|
+
version?: string;
|
|
102
|
+
status: RootStatus;
|
|
103
|
+
startedAt?: number;
|
|
104
|
+
endedAt?: number;
|
|
105
|
+
exitCode?: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Complete reporter state
|
|
110
|
+
*/
|
|
111
|
+
export type ReporterState = {
|
|
112
|
+
/** Root lifecycle state */
|
|
113
|
+
root: RootState;
|
|
114
|
+
/** Groups indexed by ID */
|
|
115
|
+
groups: Map<GroupId, GroupState>;
|
|
116
|
+
/** Activities indexed by ID */
|
|
117
|
+
activities: Map<ActivityId, ActivityState>;
|
|
118
|
+
/** Log entries in chronological order */
|
|
119
|
+
logs: LogEntry[];
|
|
120
|
+
/** Whether reporter output is suspended */
|
|
121
|
+
suspended: boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Subscription callback type
|
|
126
|
+
*/
|
|
127
|
+
export type StateListener = () => void;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reporter store interface compatible with useSyncExternalStore
|
|
131
|
+
*/
|
|
132
|
+
export type ReporterStore = {
|
|
133
|
+
/** Get current state snapshot */
|
|
134
|
+
getState(): ReporterState;
|
|
135
|
+
/** Get snapshot for useSyncExternalStore (same as getState for immutable updates) */
|
|
136
|
+
getSnapshot(): ReporterState;
|
|
137
|
+
/** Subscribe to state changes */
|
|
138
|
+
subscribe(listener: StateListener): () => void;
|
|
139
|
+
/** Get server snapshot for SSR (returns same as getSnapshot) */
|
|
140
|
+
getServerSnapshot(): ReporterState;
|
|
141
|
+
};
|