@hartvig/developer-control-center 0.8.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/.developer-control-center/metrics.json +1 -0
- package/.developer-control-center/status.json +1 -0
- package/.developer-control-center/timings.jsonl +3 -0
- package/.github/workflows/ci.yml +47 -0
- package/AGENTS.md +51 -0
- package/PLUGINS.md +145 -0
- package/README.md +147 -0
- package/developer-control-center.config.example.js +91 -0
- package/developer-control-center.config.js +177 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +223 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/loader.d.ts +4 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +96 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/loader.test.d.ts +2 -0
- package/dist/config/loader.test.d.ts.map +1 -0
- package/dist/config/loader.test.js +25 -0
- package/dist/config/loader.test.js.map +1 -0
- package/dist/config/presets/node.d.ts +10 -0
- package/dist/config/presets/node.d.ts.map +1 -0
- package/dist/config/presets/node.js +31 -0
- package/dist/config/presets/node.js.map +1 -0
- package/dist/config/presets/react.d.ts +10 -0
- package/dist/config/presets/react.d.ts.map +1 -0
- package/dist/config/presets/react.js +36 -0
- package/dist/config/presets/react.js.map +1 -0
- package/dist/config/types.d.ts +55 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config/types.test.d.ts +2 -0
- package/dist/config/types.test.d.ts.map +1 -0
- package/dist/config/types.test.js +23 -0
- package/dist/config/types.test.js.map +1 -0
- package/dist/core/ci.d.ts +6 -0
- package/dist/core/ci.d.ts.map +1 -0
- package/dist/core/ci.js +22 -0
- package/dist/core/ci.js.map +1 -0
- package/dist/core/ci.test.d.ts +2 -0
- package/dist/core/ci.test.d.ts.map +1 -0
- package/dist/core/ci.test.js +45 -0
- package/dist/core/ci.test.js.map +1 -0
- package/dist/core/event-bus.d.ts +18 -0
- package/dist/core/event-bus.d.ts.map +1 -0
- package/dist/core/event-bus.js +19 -0
- package/dist/core/event-bus.js.map +1 -0
- package/dist/core/event-bus.test.d.ts +2 -0
- package/dist/core/event-bus.test.d.ts.map +1 -0
- package/dist/core/event-bus.test.js +49 -0
- package/dist/core/event-bus.test.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/notifier.d.ts +2 -0
- package/dist/core/notifier.d.ts.map +1 -0
- package/dist/core/notifier.js +28 -0
- package/dist/core/notifier.js.map +1 -0
- package/dist/core/notifier.test.d.ts +2 -0
- package/dist/core/notifier.test.d.ts.map +1 -0
- package/dist/core/notifier.test.js +25 -0
- package/dist/core/notifier.test.js.map +1 -0
- package/dist/core/runtime.d.ts +25 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +85 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/task-runner.d.ts +26 -0
- package/dist/core/task-runner.d.ts.map +1 -0
- package/dist/core/task-runner.js +354 -0
- package/dist/core/task-runner.js.map +1 -0
- package/dist/core/timer-plugin.d.ts +3 -0
- package/dist/core/timer-plugin.d.ts.map +1 -0
- package/dist/core/timer-plugin.js +34 -0
- package/dist/core/timer-plugin.js.map +1 -0
- package/dist/core/workspaces.d.ts +6 -0
- package/dist/core/workspaces.d.ts.map +1 -0
- package/dist/core/workspaces.js +60 -0
- package/dist/core/workspaces.js.map +1 -0
- package/dist/core/workspaces.test.d.ts +2 -0
- package/dist/core/workspaces.test.d.ts.map +1 -0
- package/dist/core/workspaces.test.js +62 -0
- package/dist/core/workspaces.test.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +2 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/manager.d.ts +13 -0
- package/dist/plugins/manager.d.ts.map +1 -0
- package/dist/plugins/manager.js +43 -0
- package/dist/plugins/manager.js.map +1 -0
- package/dist/plugins/manager.test.d.ts +2 -0
- package/dist/plugins/manager.test.d.ts.map +1 -0
- package/dist/plugins/manager.test.js +79 -0
- package/dist/plugins/manager.test.js.map +1 -0
- package/dist/plugins/types.d.ts +17 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +2 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/status/index.d.ts +3 -0
- package/dist/status/index.d.ts.map +1 -0
- package/dist/status/index.js +2 -0
- package/dist/status/index.js.map +1 -0
- package/dist/status/store.d.ts +18 -0
- package/dist/status/store.d.ts.map +1 -0
- package/dist/status/store.js +76 -0
- package/dist/status/store.js.map +1 -0
- package/dist/status/store.test.d.ts +2 -0
- package/dist/status/store.test.d.ts.map +1 -0
- package/dist/status/store.test.js +107 -0
- package/dist/status/store.test.js.map +1 -0
- package/dist/status/types.d.ts +12 -0
- package/dist/status/types.d.ts.map +1 -0
- package/dist/status/types.js +2 -0
- package/dist/status/types.js.map +1 -0
- package/dist/ui/app.d.ts +10 -0
- package/dist/ui/app.d.ts.map +1 -0
- package/dist/ui/app.js +479 -0
- package/dist/ui/app.js.map +1 -0
- package/dist/ui/command-list.d.ts +30 -0
- package/dist/ui/command-list.d.ts.map +1 -0
- package/dist/ui/command-list.js +45 -0
- package/dist/ui/command-list.js.map +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/metrics-panel.d.ts +10 -0
- package/dist/ui/metrics-panel.d.ts.map +1 -0
- package/dist/ui/metrics-panel.js +139 -0
- package/dist/ui/metrics-panel.js.map +1 -0
- package/dist/ui/panel.d.ts +16 -0
- package/dist/ui/panel.d.ts.map +1 -0
- package/dist/ui/panel.js +16 -0
- package/dist/ui/panel.js.map +1 -0
- package/dist/ui/status-panel.d.ts +16 -0
- package/dist/ui/status-panel.d.ts.map +1 -0
- package/dist/ui/status-panel.js +52 -0
- package/dist/ui/status-panel.js.map +1 -0
- package/docs/architecture.md +29 -0
- package/docs/config.md +15 -0
- package/docs/mvp.md +17 -0
- package/docs/phases.md +49 -0
- package/docs/technical-decisions.md +19 -0
- package/docs/ui.md +14 -0
- package/package.json +30 -0
- package/src/cli.ts +242 -0
- package/src/config/index.ts +2 -0
- package/src/config/loader.test.ts +30 -0
- package/src/config/loader.ts +123 -0
- package/src/config/presets/node.ts +30 -0
- package/src/config/presets/react.ts +35 -0
- package/src/config/types.test.ts +24 -0
- package/src/config/types.ts +52 -0
- package/src/core/ci.test.ts +54 -0
- package/src/core/ci.ts +26 -0
- package/src/core/event-bus.test.ts +56 -0
- package/src/core/event-bus.ts +34 -0
- package/src/core/index.ts +8 -0
- package/src/core/notifier.test.ts +30 -0
- package/src/core/notifier.ts +34 -0
- package/src/core/runtime.ts +99 -0
- package/src/core/task-runner.ts +408 -0
- package/src/core/timer-plugin.ts +34 -0
- package/src/core/workspaces.test.ts +72 -0
- package/src/core/workspaces.ts +73 -0
- package/src/index.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/manager.test.ts +92 -0
- package/src/plugins/manager.ts +54 -0
- package/src/plugins/types.ts +18 -0
- package/src/status/index.ts +2 -0
- package/src/status/store.test.ts +122 -0
- package/src/status/store.ts +88 -0
- package/src/status/types.ts +12 -0
- package/src/ui/app.tsx +606 -0
- package/src/ui/command-list.tsx +163 -0
- package/src/ui/index.tsx +10 -0
- package/src/ui/metrics-panel.tsx +234 -0
- package/src/ui/panel.tsx +76 -0
- package/src/ui/status-panel.tsx +160 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { ProkomCommand } from '../config/types.js';
|
|
4
|
+
import { TaskState } from '../status/types.js';
|
|
5
|
+
import { Panel } from './panel.js';
|
|
6
|
+
|
|
7
|
+
export interface MenuGroup {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ProfileOption {
|
|
14
|
+
kind: 'profile';
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
profile?: string;
|
|
18
|
+
active: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MenuItem = ProkomCommand | MenuGroup | ProfileOption;
|
|
22
|
+
|
|
23
|
+
interface CommandListProps {
|
|
24
|
+
items: MenuItem[];
|
|
25
|
+
selectedIndex: number;
|
|
26
|
+
multiSelected?: Set<string>;
|
|
27
|
+
selCount?: number;
|
|
28
|
+
breadcrumb?: string;
|
|
29
|
+
tasks?: ReadonlyMap<string, TaskState>;
|
|
30
|
+
width: number;
|
|
31
|
+
focused?: boolean;
|
|
32
|
+
menuRows: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isGroup(item: MenuItem): item is MenuGroup {
|
|
36
|
+
return 'count' in item;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isProfileOption(item: MenuItem): item is ProfileOption {
|
|
40
|
+
return 'kind' in item && item.kind === 'profile';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const CommandList: React.FC<CommandListProps> = ({
|
|
44
|
+
items,
|
|
45
|
+
selectedIndex,
|
|
46
|
+
multiSelected,
|
|
47
|
+
selCount,
|
|
48
|
+
breadcrumb,
|
|
49
|
+
tasks,
|
|
50
|
+
width,
|
|
51
|
+
focused = false,
|
|
52
|
+
menuRows,
|
|
53
|
+
}) => {
|
|
54
|
+
const contentRows = menuRows;
|
|
55
|
+
const panelHeight = menuRows + 2;
|
|
56
|
+
const total = items.length;
|
|
57
|
+
const noCommands = total === 0;
|
|
58
|
+
|
|
59
|
+
const half = Math.floor(contentRows / 2);
|
|
60
|
+
let start = Math.max(0, selectedIndex - half);
|
|
61
|
+
start = Math.min(start, Math.max(0, total - contentRows));
|
|
62
|
+
const visible = items.slice(start, start + contentRows);
|
|
63
|
+
const hiddenAbove = start;
|
|
64
|
+
const hiddenBelow = total - (start + visible.length);
|
|
65
|
+
const padRows = Math.max(0, contentRows - visible.length - (noCommands ? 1 : 0));
|
|
66
|
+
|
|
67
|
+
function getLabel(item: ProkomCommand): string {
|
|
68
|
+
if (!item.toggle) return item.label;
|
|
69
|
+
const task = tasks?.get(item.id);
|
|
70
|
+
return task?.status === 'running' ? `Stop ${item.label}` : `Start ${item.label}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Panel
|
|
75
|
+
title="Commands"
|
|
76
|
+
titleColor={focused ? 'cyan' : 'white'}
|
|
77
|
+
borderColor={focused ? 'cyan' : 'white'}
|
|
78
|
+
height={panelHeight}
|
|
79
|
+
width={width}
|
|
80
|
+
hiddenAbove={hiddenAbove}
|
|
81
|
+
hiddenBelow={hiddenBelow}
|
|
82
|
+
titleExtraWidth={(selCount && selCount > 0 ? ` (${selCount} selected)`.length : 0) + (breadcrumb ? ` ${breadcrumb}`.length : 0)}
|
|
83
|
+
titleExtra={(
|
|
84
|
+
<>
|
|
85
|
+
{selCount && selCount > 0 ? (
|
|
86
|
+
<Text color="green"> ({selCount} selected)</Text>
|
|
87
|
+
) : null}
|
|
88
|
+
{breadcrumb ? (
|
|
89
|
+
<Text color="gray"> {breadcrumb}</Text>
|
|
90
|
+
) : null}
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{total === 0 && (
|
|
95
|
+
<Box paddingLeft={1}>
|
|
96
|
+
<Text color="gray">No commands configured</Text>
|
|
97
|
+
</Box>
|
|
98
|
+
)}
|
|
99
|
+
{visible.map((item, i) => {
|
|
100
|
+
const actualIndex = start + i;
|
|
101
|
+
const isCursor = actualIndex === selectedIndex;
|
|
102
|
+
const cursor = isCursor ? '❯' : ' ';
|
|
103
|
+
|
|
104
|
+
if (isProfileOption(item)) {
|
|
105
|
+
return (
|
|
106
|
+
<Box key={item.id} paddingLeft={1}>
|
|
107
|
+
<Text color={isCursor ? 'green' : item.active ? 'cyan' : undefined}>
|
|
108
|
+
{cursor} {item.active ? '●' : '○'} {item.label}
|
|
109
|
+
</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!isGroup(item)) {
|
|
115
|
+
const checked = multiSelected?.has(item.id);
|
|
116
|
+
const sel = checked ? '\u2713' : ' ';
|
|
117
|
+
const prefix = `${cursor}[${sel}]`;
|
|
118
|
+
return (
|
|
119
|
+
<Box key={item.id} paddingLeft={1}>
|
|
120
|
+
<Text color={isCursor ? 'green' : undefined}>
|
|
121
|
+
{prefix} {getLabel(item)}
|
|
122
|
+
{item.cwd ? (
|
|
123
|
+
<Text color={isCursor ? 'green' : 'gray'}>
|
|
124
|
+
{' ['}{item.cwd}{']'}
|
|
125
|
+
</Text>
|
|
126
|
+
) : null}
|
|
127
|
+
{item.parallel ? (
|
|
128
|
+
<Text color="yellow"> [parallel]</Text>
|
|
129
|
+
) : null}
|
|
130
|
+
{item.pipelineSteps ? (
|
|
131
|
+
<Text color="cyan"> [pipeline]</Text>
|
|
132
|
+
) : null}
|
|
133
|
+
{item.parallelSteps ? (
|
|
134
|
+
<Text color="yellow"> [parallel {item.parallelSteps.length}]</Text>
|
|
135
|
+
) : null}
|
|
136
|
+
{item.watch ? (
|
|
137
|
+
<Text color="magenta"> [watch]</Text>
|
|
138
|
+
) : null}
|
|
139
|
+
</Text>
|
|
140
|
+
</Box>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const group = item as MenuGroup;
|
|
145
|
+
return (
|
|
146
|
+
<Box key={group.id} paddingLeft={1}>
|
|
147
|
+
<Text color={isCursor ? 'green' : 'yellow'}>
|
|
148
|
+
{cursor} <Text bold>▶ {group.label}</Text>
|
|
149
|
+
{' '}
|
|
150
|
+
<Text color={isCursor ? 'green' : 'gray'}>
|
|
151
|
+
({group.count})
|
|
152
|
+
</Text>
|
|
153
|
+
</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
|
|
158
|
+
{Array.from({ length: padRows }).map((_, i) => (
|
|
159
|
+
<Box key={`pad-${i}`} height={1}><Text> </Text></Box>
|
|
160
|
+
))}
|
|
161
|
+
</Panel>
|
|
162
|
+
);
|
|
163
|
+
};
|
package/src/ui/index.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { render } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { App } from './app.js';
|
|
4
|
+
import { Runtime } from '../core/index.js';
|
|
5
|
+
import { ProkomConfig } from '../config/index.js';
|
|
6
|
+
|
|
7
|
+
export function startUI(config: ProkomConfig, runtime: Runtime) {
|
|
8
|
+
const { waitUntilExit } = render(<App config={config} runtime={runtime} />);
|
|
9
|
+
return waitUntilExit();
|
|
10
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { TaskState, TaskStatus } from '../status/types.js';
|
|
6
|
+
import { Panel } from './panel.js';
|
|
7
|
+
|
|
8
|
+
interface MetricsPanelProps {
|
|
9
|
+
tasks: Map<string, TaskState>;
|
|
10
|
+
menuRows: number;
|
|
11
|
+
width: number;
|
|
12
|
+
}
|
|
13
|
+
const METRICS_FILE = path.join(process.cwd(), '.developer-control-center', 'metrics.json');
|
|
14
|
+
|
|
15
|
+
interface MetricsState {
|
|
16
|
+
projectRunning: boolean;
|
|
17
|
+
activeTasks: number;
|
|
18
|
+
latestTest?: {
|
|
19
|
+
label: string;
|
|
20
|
+
status: TaskStatus;
|
|
21
|
+
time?: number;
|
|
22
|
+
};
|
|
23
|
+
latestBuild?: {
|
|
24
|
+
status: TaskStatus;
|
|
25
|
+
time?: number;
|
|
26
|
+
};
|
|
27
|
+
lastGitPush?: {
|
|
28
|
+
label: string;
|
|
29
|
+
time?: number;
|
|
30
|
+
};
|
|
31
|
+
packageVersion: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const INITIAL_METRICS: MetricsState = {
|
|
35
|
+
projectRunning: false,
|
|
36
|
+
activeTasks: 0,
|
|
37
|
+
packageVersion: loadPackageVersion(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function loadPackageVersion(): string {
|
|
41
|
+
try {
|
|
42
|
+
const packageJson = JSON.parse(
|
|
43
|
+
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'),
|
|
44
|
+
) as { version?: string };
|
|
45
|
+
return packageJson.version ?? '-';
|
|
46
|
+
} catch {
|
|
47
|
+
return '-';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadMetrics(): MetricsState {
|
|
52
|
+
try {
|
|
53
|
+
const persisted = JSON.parse(fs.readFileSync(METRICS_FILE, 'utf-8')) as MetricsState;
|
|
54
|
+
return {
|
|
55
|
+
...INITIAL_METRICS,
|
|
56
|
+
latestTest: persisted.latestTest,
|
|
57
|
+
latestBuild: persisted.latestBuild,
|
|
58
|
+
lastGitPush: persisted.lastGitPush,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return INITIAL_METRICS;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isBumpTask(task: TaskState): boolean {
|
|
66
|
+
return task.id === 'bump-version';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveMetrics(metrics: MetricsState): void {
|
|
70
|
+
try {
|
|
71
|
+
fs.mkdirSync(path.dirname(METRICS_FILE), { recursive: true });
|
|
72
|
+
fs.writeFileSync(METRICS_FILE, JSON.stringify(metrics), 'utf-8');
|
|
73
|
+
} catch {
|
|
74
|
+
// Metrics are useful but non-critical.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isTestTask(task: TaskState): boolean {
|
|
79
|
+
const name = `${task.id} ${task.label}`.toLowerCase();
|
|
80
|
+
return name.includes('test');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isBuildTask(task: TaskState): boolean {
|
|
84
|
+
const name = `${task.id} ${task.label}`.toLowerCase();
|
|
85
|
+
return name.includes('build');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSuccessfulGitPushTask(task: TaskState): boolean {
|
|
89
|
+
const name = `${task.id} ${task.label}`.toLowerCase();
|
|
90
|
+
return name.includes('push') && task.status === 'success';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatTimestamp(time?: number): string | null {
|
|
94
|
+
if (!time) return null;
|
|
95
|
+
const date = new Date(time);
|
|
96
|
+
const year = date.getFullYear();
|
|
97
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
98
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
99
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
100
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
101
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatSince(time?: number): string | null {
|
|
105
|
+
if (!time) return null;
|
|
106
|
+
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
|
|
107
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
108
|
+
const minutes = Math.floor(seconds / 60);
|
|
109
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
110
|
+
return `${Math.floor(minutes / 60)}h ago`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const MetricsPanel: React.FC<MetricsPanelProps> = ({ tasks, menuRows, width }) => {
|
|
114
|
+
const [metrics, setMetrics] = useState<MetricsState>(loadMetrics);
|
|
115
|
+
const latestTestTime = formatSince(metrics.latestTest?.time);
|
|
116
|
+
const latestBuildTime = formatSince(metrics.latestBuild?.time);
|
|
117
|
+
const lastGitPushTime = formatTimestamp(metrics.lastGitPush?.time);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
setMetrics((previous) => {
|
|
121
|
+
const entries = Array.from(tasks.values());
|
|
122
|
+
|
|
123
|
+
const hasBumpCompleted = entries.some(
|
|
124
|
+
(t) => isBumpTask(t) && (t.status === 'success' || t.status === 'failure'),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const running = entries.filter((t) => t.status === 'running');
|
|
128
|
+
|
|
129
|
+
const latestTest = entries
|
|
130
|
+
.filter(isTestTask)
|
|
131
|
+
.sort((a, b) => (b.endTime ?? b.startTime ?? 0) - (a.endTime ?? a.startTime ?? 0))[0];
|
|
132
|
+
|
|
133
|
+
const latestBuild = entries
|
|
134
|
+
.filter(isBuildTask)
|
|
135
|
+
.sort((a, b) => (b.endTime ?? b.startTime ?? 0) - (a.endTime ?? a.startTime ?? 0))[0];
|
|
136
|
+
|
|
137
|
+
const latestPush = entries
|
|
138
|
+
.filter(isSuccessfulGitPushTask)
|
|
139
|
+
.sort((a, b) => (b.endTime ?? b.startTime ?? 0) - (a.endTime ?? a.startTime ?? 0))[0];
|
|
140
|
+
|
|
141
|
+
const next: MetricsState = {
|
|
142
|
+
projectRunning: running.length > 0,
|
|
143
|
+
activeTasks: running.length,
|
|
144
|
+
packageVersion: hasBumpCompleted ? loadPackageVersion() : previous.packageVersion,
|
|
145
|
+
latestTest: latestTest?.label && latestTest?.status
|
|
146
|
+
? {
|
|
147
|
+
label: latestTest.label,
|
|
148
|
+
status: latestTest.status,
|
|
149
|
+
time: latestTest.endTime ?? latestTest.startTime,
|
|
150
|
+
}
|
|
151
|
+
: previous.latestTest,
|
|
152
|
+
latestBuild: latestBuild?.status
|
|
153
|
+
? {
|
|
154
|
+
status: latestBuild.status,
|
|
155
|
+
time: latestBuild.endTime ?? latestBuild.startTime,
|
|
156
|
+
}
|
|
157
|
+
: previous.latestBuild,
|
|
158
|
+
lastGitPush: latestPush?.label
|
|
159
|
+
? {
|
|
160
|
+
label: latestPush.label,
|
|
161
|
+
time: latestPush.endTime ?? latestPush.startTime,
|
|
162
|
+
}
|
|
163
|
+
: previous.lastGitPush,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
saveMetrics(next);
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}, [tasks]);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<Panel title="Status" width={width} height={menuRows + 2}>
|
|
173
|
+
<Box paddingLeft={1}>
|
|
174
|
+
<Text color={metrics.projectRunning ? 'yellow' : 'gray'}>
|
|
175
|
+
Project: {metrics.projectRunning ? 'running' : 'idle'}
|
|
176
|
+
</Text>
|
|
177
|
+
</Box>
|
|
178
|
+
|
|
179
|
+
<Box paddingLeft={1}>
|
|
180
|
+
<Text color="gray">
|
|
181
|
+
Active: {metrics.activeTasks}
|
|
182
|
+
</Text>
|
|
183
|
+
</Box>
|
|
184
|
+
|
|
185
|
+
<Box paddingLeft={1}>
|
|
186
|
+
<Text color="gray">
|
|
187
|
+
Version: {metrics.packageVersion}
|
|
188
|
+
</Text>
|
|
189
|
+
</Box>
|
|
190
|
+
|
|
191
|
+
<Box paddingLeft={1}>
|
|
192
|
+
<Text color="gray">{'\u2500'.repeat(Math.max(0, width - 4))}</Text>
|
|
193
|
+
</Box>
|
|
194
|
+
|
|
195
|
+
<Box paddingLeft={1}>
|
|
196
|
+
{!metrics.latestTest ? (
|
|
197
|
+
<Text color="gray">Tests: no run</Text>
|
|
198
|
+
) : (
|
|
199
|
+
<Text color={metrics.latestTest.status === 'success' ? 'green' :
|
|
200
|
+
metrics.latestTest.status === 'failure' ? 'red' :
|
|
201
|
+
metrics.latestTest.status === 'running' ? 'yellow' : 'gray'}
|
|
202
|
+
>
|
|
203
|
+
Tests: {metrics.latestTest.status === 'success' ? 'passed' :
|
|
204
|
+
metrics.latestTest.status === 'failure' ? 'failed' :
|
|
205
|
+
metrics.latestTest.status === 'running' ? 'running' : 'idle'}
|
|
206
|
+
{latestTestTime ? <Text color="gray"> - {latestTestTime}</Text> : null}
|
|
207
|
+
</Text>
|
|
208
|
+
)}
|
|
209
|
+
</Box>
|
|
210
|
+
|
|
211
|
+
<Box paddingLeft={1}>
|
|
212
|
+
{!metrics.latestBuild ? (
|
|
213
|
+
<Text color="gray">Build: no run</Text>
|
|
214
|
+
) : (
|
|
215
|
+
<Text color={metrics.latestBuild.status === 'success' ? 'green' :
|
|
216
|
+
metrics.latestBuild.status === 'failure' ? 'red' :
|
|
217
|
+
metrics.latestBuild.status === 'running' ? 'yellow' : 'gray'}
|
|
218
|
+
>
|
|
219
|
+
Build: {metrics.latestBuild.status === 'success' ? 'passed' :
|
|
220
|
+
metrics.latestBuild.status === 'failure' ? 'failed' :
|
|
221
|
+
metrics.latestBuild.status === 'running' ? 'running' : 'idle'}
|
|
222
|
+
{latestBuildTime ? <Text color="gray"> - {latestBuildTime}</Text> : null}
|
|
223
|
+
</Text>
|
|
224
|
+
)}
|
|
225
|
+
</Box>
|
|
226
|
+
|
|
227
|
+
<Box paddingLeft={1}>
|
|
228
|
+
<Text color="gray" wrap="truncate-end">
|
|
229
|
+
Push: {lastGitPushTime ?? '-'}
|
|
230
|
+
</Text>
|
|
231
|
+
</Box>
|
|
232
|
+
</Panel>
|
|
233
|
+
);
|
|
234
|
+
};
|
package/src/ui/panel.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface PanelProps {
|
|
5
|
+
title: string;
|
|
6
|
+
titleColor?: string;
|
|
7
|
+
borderColor?: string;
|
|
8
|
+
titleExtra?: ReactNode;
|
|
9
|
+
titleExtraWidth?: number;
|
|
10
|
+
hiddenAbove?: number;
|
|
11
|
+
hiddenBelow?: number;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
height: number;
|
|
14
|
+
width: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Panel: React.FC<PanelProps> = ({
|
|
18
|
+
title,
|
|
19
|
+
titleColor = 'cyan',
|
|
20
|
+
borderColor = 'white',
|
|
21
|
+
titleExtra,
|
|
22
|
+
titleExtraWidth = 0,
|
|
23
|
+
hiddenAbove,
|
|
24
|
+
hiddenBelow,
|
|
25
|
+
children,
|
|
26
|
+
height,
|
|
27
|
+
width,
|
|
28
|
+
}) => {
|
|
29
|
+
const contentHeight = Math.max(0, height - 2);
|
|
30
|
+
const innerWidth = Math.max(0, width - 2);
|
|
31
|
+
|
|
32
|
+
const showAbove = hiddenAbove != null && hiddenAbove > 0;
|
|
33
|
+
const showBelow = hiddenBelow != null && hiddenBelow > 0;
|
|
34
|
+
const aboveText = showAbove ? `↑${hiddenAbove} more` : '';
|
|
35
|
+
const belowText = showBelow ? `↓${hiddenBelow} more` : '';
|
|
36
|
+
const aboveLen = showAbove ? aboveText.length + 1 : 0;
|
|
37
|
+
const belowLen = showBelow ? belowText.length + 1 : 0;
|
|
38
|
+
|
|
39
|
+
const topFillWidth = Math.max(0, width - title.length - titleExtraWidth - aboveLen - 5);
|
|
40
|
+
const bottomFillWidth = Math.max(0, width - belowLen - 2);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Box flexDirection="column" height={height} width={width}>
|
|
44
|
+
<Box width={width}>
|
|
45
|
+
<Text color={borderColor}>╭─ </Text>
|
|
46
|
+
<Text color={titleColor}>{title}</Text>
|
|
47
|
+
{titleExtra}
|
|
48
|
+
<Text color={borderColor}> </Text>
|
|
49
|
+
<Text color={borderColor}>{'─'.repeat(topFillWidth)}</Text>
|
|
50
|
+
{aboveText ? <Text color="gray">{aboveText}</Text> : null}
|
|
51
|
+
<Text color={borderColor}>{aboveText ? '─' : ''}╮</Text>
|
|
52
|
+
</Box>
|
|
53
|
+
<Box height={contentHeight} width={width}>
|
|
54
|
+
<Box flexDirection="column" width={1}>
|
|
55
|
+
{Array.from({ length: contentHeight }).map((_, index) => (
|
|
56
|
+
<Text key={`left-${index}`} color={borderColor}>│</Text>
|
|
57
|
+
))}
|
|
58
|
+
</Box>
|
|
59
|
+
<Box flexDirection="column" width={innerWidth} height={contentHeight} overflow="hidden">
|
|
60
|
+
{children}
|
|
61
|
+
</Box>
|
|
62
|
+
<Box flexDirection="column" width={1}>
|
|
63
|
+
{Array.from({ length: contentHeight }).map((_, index) => (
|
|
64
|
+
<Text key={`right-${index}`} color={borderColor}>│</Text>
|
|
65
|
+
))}
|
|
66
|
+
</Box>
|
|
67
|
+
</Box>
|
|
68
|
+
<Box width={width}>
|
|
69
|
+
<Text color={borderColor}>╰</Text>
|
|
70
|
+
<Text color={borderColor}>{'─'.repeat(bottomFillWidth)}</Text>
|
|
71
|
+
{belowText ? <Text color="gray">{belowText}</Text> : null}
|
|
72
|
+
<Text color={borderColor}>{belowText ? '─' : ''}╯</Text>
|
|
73
|
+
</Box>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { ProkomCommand } from '../config/types.js';
|
|
4
|
+
import { TaskState } from '../status/types.js';
|
|
5
|
+
import { Panel } from './panel.js';
|
|
6
|
+
|
|
7
|
+
function sanitizeOutput(text: string): string {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
|
|
10
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
|
11
|
+
.replace(/\r\n/g, '\n')
|
|
12
|
+
.replace(/\r/g, '\n');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StatusPanelProps {
|
|
16
|
+
tasks: Map<string, TaskState>;
|
|
17
|
+
scrollOffsets: Map<string, number>;
|
|
18
|
+
focusedPane: 'commands' | 'status';
|
|
19
|
+
confirmingCommand?: ProkomCommand | null;
|
|
20
|
+
inputCommand?: ProkomCommand | null;
|
|
21
|
+
inputValue?: string;
|
|
22
|
+
width: number;
|
|
23
|
+
menuRows: number;
|
|
24
|
+
}
|
|
25
|
+
const TASK_HEADER_ROWS = 1;
|
|
26
|
+
|
|
27
|
+
const STATUS_STYLE: Record<TaskState['status'], { icon: string; color: string; label: string }> = {
|
|
28
|
+
idle: { icon: '\u25CB', color: 'gray', label: 'IDLE' },
|
|
29
|
+
running: { icon: '\u25CF', color: 'yellow', label: 'RUNNING' },
|
|
30
|
+
success: { icon: '\u2713', color: 'green', label: 'PASS' },
|
|
31
|
+
failure: { icon: '\u2717', color: 'red', label: 'FAIL' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function formatDuration(ms: number): string {
|
|
35
|
+
if (ms < 1000) return `${ms}ms`;
|
|
36
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
37
|
+
const m = Math.floor(ms / 60000);
|
|
38
|
+
const s = Math.floor((ms % 60000) / 1000);
|
|
39
|
+
return `${m}m${s}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const StatusPanel: React.FC<StatusPanelProps> = ({
|
|
43
|
+
tasks,
|
|
44
|
+
scrollOffsets,
|
|
45
|
+
focusedPane,
|
|
46
|
+
confirmingCommand,
|
|
47
|
+
inputCommand,
|
|
48
|
+
inputValue = '',
|
|
49
|
+
width,
|
|
50
|
+
menuRows,
|
|
51
|
+
}) => {
|
|
52
|
+
const contentRows = menuRows;
|
|
53
|
+
const promptRows = confirmingCommand ? 1 : inputCommand ? 3 : 0;
|
|
54
|
+
const outputRows = contentRows - TASK_HEADER_ROWS - promptRows;
|
|
55
|
+
const entries = Array.from(tasks.values()).sort(
|
|
56
|
+
(a, b) => (b.startTime || 0) - (a.startTime || 0),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const isFocused = focusedPane === 'status';
|
|
60
|
+
const statusColor = isFocused ? 'cyan' : 'white';
|
|
61
|
+
const task = entries[0];
|
|
62
|
+
const cleaned = task?.output ? sanitizeOutput(task.output) : '';
|
|
63
|
+
const lines = cleaned ? cleaned.split('\n') : [];
|
|
64
|
+
const offset = task ? (scrollOffsets.get(task.id) ?? 0) : 0;
|
|
65
|
+
const endLine = Math.max(0, lines.length - offset);
|
|
66
|
+
const startLine = Math.max(0, endLine - outputRows);
|
|
67
|
+
const showLines = lines.slice(startLine, endLine);
|
|
68
|
+
const hiddenAbove = startLine;
|
|
69
|
+
const hiddenBelow = offset;
|
|
70
|
+
const titleExtraWidth = hiddenAbove > 0 ? ` ↑ ${hiddenAbove} above`.length : 0;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Panel
|
|
74
|
+
title="Output"
|
|
75
|
+
titleColor={isFocused ? 'cyan' : 'white'}
|
|
76
|
+
borderColor={statusColor}
|
|
77
|
+
width={width}
|
|
78
|
+
height={menuRows + 2}
|
|
79
|
+
hiddenAbove={hiddenAbove}
|
|
80
|
+
hiddenBelow={hiddenBelow}
|
|
81
|
+
titleExtraWidth={titleExtraWidth}
|
|
82
|
+
titleExtra={hiddenAbove > 0 ? (
|
|
83
|
+
<Text color="gray"> ↑ {hiddenAbove} above</Text>
|
|
84
|
+
) : null}
|
|
85
|
+
>
|
|
86
|
+
|
|
87
|
+
{confirmingCommand ? (
|
|
88
|
+
<Box paddingLeft={2}>
|
|
89
|
+
<Text color="yellow">⚠ Run </Text>
|
|
90
|
+
<Text bold color="cyan">{confirmingCommand.label}</Text>
|
|
91
|
+
<Text color="yellow">?</Text>
|
|
92
|
+
<Text color="gray"> Enter/Y to confirm, Esc/N to cancel</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
) : null}
|
|
95
|
+
|
|
96
|
+
{inputCommand ? (
|
|
97
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
98
|
+
<Box>
|
|
99
|
+
<Text color="cyan">{inputCommand.input?.message ?? 'Input:'}</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
<Box marginTop={1}>
|
|
102
|
+
<Text wrap="truncate-end">
|
|
103
|
+
<Text>{inputValue}</Text>
|
|
104
|
+
<Text color="gray">{inputValue ? '█' : (inputCommand.input?.placeholder ?? '')}</Text>
|
|
105
|
+
</Text>
|
|
106
|
+
</Box>
|
|
107
|
+
<Box marginTop={1}>
|
|
108
|
+
<Text color="gray">Enter to confirm, Esc to cancel</Text>
|
|
109
|
+
</Box>
|
|
110
|
+
</Box>
|
|
111
|
+
) : null}
|
|
112
|
+
|
|
113
|
+
{!task && !confirmingCommand && !inputCommand && (
|
|
114
|
+
<Box paddingLeft={2}>
|
|
115
|
+
<Text color="gray">No tasks yet</Text>
|
|
116
|
+
</Box>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{task ? (() => {
|
|
120
|
+
const style = STATUS_STYLE[task.status];
|
|
121
|
+
const duration = task.startTime
|
|
122
|
+
? formatDuration((task.endTime ?? Date.now()) - task.startTime)
|
|
123
|
+
: null;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Box key={task.id} flexDirection="column">
|
|
127
|
+
<Box paddingLeft={1}>
|
|
128
|
+
<Text color={style.color}>
|
|
129
|
+
{style.icon} {task.label}
|
|
130
|
+
</Text>
|
|
131
|
+
<Text color={style.color}>
|
|
132
|
+
{' ['}{style.label}{']'}
|
|
133
|
+
</Text>
|
|
134
|
+
{duration ? (
|
|
135
|
+
<Text color="gray"> ({duration})</Text>
|
|
136
|
+
) : null}
|
|
137
|
+
{task.exitCode !== undefined && task.exitCode !== 0 ? (
|
|
138
|
+
<Text color="red"> exit {task.exitCode}</Text>
|
|
139
|
+
) : null}
|
|
140
|
+
{task.watchMode && (
|
|
141
|
+
<Text color="magenta"> [WATCH]</Text>
|
|
142
|
+
)}
|
|
143
|
+
</Box>
|
|
144
|
+
{task.output ? (
|
|
145
|
+
<Box paddingLeft={2} flexDirection="column">
|
|
146
|
+
<Box paddingLeft={1} flexDirection="column">
|
|
147
|
+
{showLines.map((line, index) => (
|
|
148
|
+
<Text key={`${startLine + index}-${line}`} wrap="truncate-end">
|
|
149
|
+
{line || ' '}
|
|
150
|
+
</Text>
|
|
151
|
+
))}
|
|
152
|
+
</Box>
|
|
153
|
+
</Box>
|
|
154
|
+
) : null}
|
|
155
|
+
</Box>
|
|
156
|
+
);
|
|
157
|
+
})() : null}
|
|
158
|
+
</Panel>
|
|
159
|
+
);
|
|
160
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"outDir": "dist",
|
|
17
|
+
"rootDir": "src",
|
|
18
|
+
"composite": false
|
|
19
|
+
},
|
|
20
|
+
"include": ["src"]
|
|
21
|
+
}
|