@hartvig/developer-control-center 0.8.5 → 0.8.7
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 -1
- package/.developer-control-center/status.json +1 -1
- package/.developer-control-center/timings.jsonl +24 -0
- package/.github/workflows/ci.yml +1 -7
- package/coverage/Developer Control Center/dcc.config.js.html +628 -0
- package/coverage/Developer Control Center/index.html +116 -0
- package/coverage/Developer Control Center/src/config/index.html +116 -0
- package/coverage/Developer Control Center/src/config/loader.ts.html +454 -0
- package/coverage/Developer Control Center/src/core/ci.ts.html +163 -0
- package/coverage/Developer Control Center/src/core/event-bus.ts.html +187 -0
- package/coverage/Developer Control Center/src/core/index.html +191 -0
- package/coverage/Developer Control Center/src/core/notifier.ts.html +187 -0
- package/coverage/Developer Control Center/src/core/persistence.ts.html +88 -0
- package/coverage/Developer Control Center/src/core/task-runner.ts.html +1498 -0
- package/coverage/Developer Control Center/src/core/workspaces.ts.html +304 -0
- package/coverage/Developer Control Center/src/plugins/index.html +116 -0
- package/coverage/Developer Control Center/src/plugins/manager.ts.html +259 -0
- package/coverage/Developer Control Center/src/status/index.html +116 -0
- package/coverage/Developer Control Center/src/status/store.ts.html +349 -0
- package/coverage/Developer Control Center/src/ui/command-list.tsx.html +574 -0
- package/coverage/Developer Control Center/src/ui/index.html +161 -0
- package/coverage/Developer Control Center/src/ui/metrics-panel.tsx.html +787 -0
- package/coverage/Developer Control Center/src/ui/panel.tsx.html +313 -0
- package/coverage/Developer Control Center/src/ui/status-panel.tsx.html +565 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +588 -0
- package/coverage/coverage-final.json +15 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +191 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/dcc.config.js +8 -4
- package/dist/cli.js +0 -0
- package/dist/core/persistence.d.ts +2 -0
- package/dist/core/persistence.d.ts.map +1 -0
- package/dist/core/persistence.js +2 -0
- package/dist/core/persistence.js.map +1 -0
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +5 -2
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/task-runner.d.ts +1 -0
- package/dist/core/task-runner.d.ts.map +1 -1
- package/dist/core/task-runner.js +24 -4
- package/dist/core/task-runner.js.map +1 -1
- package/dist/core/task-runner.test.d.ts +2 -0
- package/dist/core/task-runner.test.d.ts.map +1 -0
- package/dist/core/task-runner.test.js +326 -0
- package/dist/core/task-runner.test.js.map +1 -0
- package/dist/core/timer-plugin.d.ts.map +1 -1
- package/dist/core/timer-plugin.js +2 -1
- package/dist/core/timer-plugin.js.map +1 -1
- package/dist/plugins/manager.d.ts +2 -0
- package/dist/plugins/manager.d.ts.map +1 -1
- package/dist/plugins/manager.js +6 -2
- package/dist/plugins/manager.js.map +1 -1
- package/dist/plugins/manager.test.js +5 -2
- package/dist/plugins/manager.test.js.map +1 -1
- package/dist/ui/app.d.ts.map +1 -1
- package/dist/ui/app.js +106 -19
- package/dist/ui/app.js.map +1 -1
- package/dist/ui/command-list.test.d.ts +2 -0
- package/dist/ui/command-list.test.d.ts.map +1 -0
- package/dist/ui/command-list.test.js +104 -0
- package/dist/ui/command-list.test.js.map +1 -0
- package/dist/ui/metrics-panel.d.ts.map +1 -1
- package/dist/ui/metrics-panel.js +10 -9
- package/dist/ui/metrics-panel.js.map +1 -1
- package/dist/ui/metrics-panel.test.d.ts +2 -0
- package/dist/ui/metrics-panel.test.d.ts.map +1 -0
- package/dist/ui/metrics-panel.test.js +111 -0
- package/dist/ui/metrics-panel.test.js.map +1 -0
- package/dist/ui/panel.test.d.ts +2 -0
- package/dist/ui/panel.test.d.ts.map +1 -0
- package/dist/ui/panel.test.js +51 -0
- package/dist/ui/panel.test.js.map +1 -0
- package/dist/ui/status-panel.test.d.ts +2 -0
- package/dist/ui/status-panel.test.d.ts.map +1 -0
- package/dist/ui/status-panel.test.js +88 -0
- package/dist/ui/status-panel.test.js.map +1 -0
- package/package.json +3 -1
- package/src/core/persistence.ts +1 -0
- package/src/core/runtime.ts +7 -2
- package/src/core/task-runner.test.ts +395 -0
- package/src/core/task-runner.ts +26 -5
- package/src/core/timer-plugin.ts +2 -1
- package/src/plugins/manager.test.ts +5 -2
- package/src/plugins/manager.ts +6 -2
- package/src/ui/app.tsx +151 -31
- package/src/ui/command-list.test.tsx +124 -0
- package/src/ui/metrics-panel.test.tsx +128 -0
- package/src/ui/metrics-panel.tsx +10 -10
- package/src/ui/panel.test.tsx +84 -0
- package/src/ui/status-panel.test.tsx +116 -0
- package/vitest.config.ts +1 -1
package/src/ui/app.tsx
CHANGED
|
@@ -13,7 +13,7 @@ interface AppProps {
|
|
|
13
13
|
runtime: Runtime;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup';
|
|
16
|
+
type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup' | 'help';
|
|
17
17
|
|
|
18
18
|
interface PopupState {
|
|
19
19
|
title: string;
|
|
@@ -26,6 +26,35 @@ type Pane = 'commands' | 'status';
|
|
|
26
26
|
const PROFILE_GROUP_ID = '__profiles';
|
|
27
27
|
const GROUP_ORDER = ['Development', 'Build', 'Deploy', 'Management', 'Demo'];
|
|
28
28
|
|
|
29
|
+
type HelpEntry =
|
|
30
|
+
| { kind: 'header'; text: string }
|
|
31
|
+
| { kind: 'key'; key: string; desc: string }
|
|
32
|
+
| { kind: 'spacer' }
|
|
33
|
+
| { kind: 'hint' };
|
|
34
|
+
|
|
35
|
+
const helpEntries: HelpEntry[] = [
|
|
36
|
+
{ kind: 'header', text: 'Navigation' },
|
|
37
|
+
{ kind: 'key', key: '↑/↓', desc: 'Navigate commands' },
|
|
38
|
+
{ kind: 'key', key: 'Enter', desc: 'Run selected command' },
|
|
39
|
+
{ kind: 'key', key: 'Space', desc: 'Multi-select / open group' },
|
|
40
|
+
{ kind: 'key', key: 'Tab', desc: 'Toggle pane focus' },
|
|
41
|
+
{ kind: 'key', key: '/', desc: 'Search/filter commands' },
|
|
42
|
+
{ kind: 'spacer' },
|
|
43
|
+
{ kind: 'header', text: 'Output pane' },
|
|
44
|
+
{ kind: 'key', key: '↑/↓', desc: 'Scroll line by line' },
|
|
45
|
+
{ kind: 'key', key: 'PgUp/PgDn', desc: 'Scroll 10 lines' },
|
|
46
|
+
{ kind: 'key', key: 'Esc', desc: 'Back to commands' },
|
|
47
|
+
{ kind: 'spacer' },
|
|
48
|
+
{ kind: 'header', text: 'Global' },
|
|
49
|
+
{ kind: 'key', key: '?', desc: 'Toggle this help' },
|
|
50
|
+
{ kind: 'key', key: 'Esc', desc: 'Back / deselect / quit' },
|
|
51
|
+
{ kind: 'key', key: 'Ctrl+C', desc: 'Quit' },
|
|
52
|
+
{ kind: 'spacer' },
|
|
53
|
+
{ kind: 'hint' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const HELP_CONTENT_LINES = helpEntries.length;
|
|
57
|
+
|
|
29
58
|
function commandsForProfile(config: ProkomConfig, profile?: string): ProkomCommand[] {
|
|
30
59
|
const base = config.baseCommands ?? config.commands;
|
|
31
60
|
const profileCommands = profile ? (config.profiles?.[profile]?.commands ?? []) : [];
|
|
@@ -68,14 +97,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
68
97
|
);
|
|
69
98
|
const [currentGroup, setCurrentGroup] = useState<string | null>(null);
|
|
70
99
|
const [multiSelected, setMultiSelected] = useState<Set<string>>(new Set());
|
|
100
|
+
const [helpScroll, setHelpScroll] = useState(0);
|
|
71
101
|
const [activeProfile, setActiveProfile] = useState<string | undefined>(config.profile);
|
|
72
|
-
const
|
|
102
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
103
|
+
const dynamicRows = Math.max(4, terminalRows - 7);
|
|
104
|
+
const menuRows = config.menuRows ?? dynamicRows;
|
|
73
105
|
const outputRows = config.outputRows ?? menuRows;
|
|
74
106
|
const terminalColumns = stdout?.columns ?? 120;
|
|
75
|
-
const availablePaneWidth = Math.max(90, terminalColumns - 4);
|
|
76
107
|
const statusPaneWidth = 28;
|
|
77
|
-
const commandPaneWidth = Math.min(52, Math.max(34, Math.floor(availablePaneWidth * 0.34)));
|
|
78
|
-
const outputPaneWidth = Math.max(30, availablePaneWidth - commandPaneWidth - statusPaneWidth - 2);
|
|
79
108
|
|
|
80
109
|
const activeCommands = useMemo(
|
|
81
110
|
() => commandsForProfile(config, activeProfile),
|
|
@@ -84,7 +113,19 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
84
113
|
|
|
85
114
|
useEffect(() => {
|
|
86
115
|
runtime.taskRunner.setCommands(activeCommands);
|
|
87
|
-
}, [activeCommands, runtime]);
|
|
116
|
+
}, [activeCommands, runtime] );
|
|
117
|
+
|
|
118
|
+
const commandPaneWidth = useMemo(() => {
|
|
119
|
+
const padding = 6;
|
|
120
|
+
const maxLabel = activeCommands.reduce((max, cmd) => {
|
|
121
|
+
const label = cmd.toggle ? `Start ${cmd.label}` : cmd.label;
|
|
122
|
+
return Math.max(max, label.length);
|
|
123
|
+
}, 0);
|
|
124
|
+
return Math.min(Math.max(maxLabel + padding + 2, 20), terminalColumns - statusPaneWidth - 6);
|
|
125
|
+
}, [activeCommands, terminalColumns, statusPaneWidth]);
|
|
126
|
+
|
|
127
|
+
const outputPaneWidth = Math.max(20, terminalColumns - commandPaneWidth - statusPaneWidth - 4);
|
|
128
|
+
const helpScrollMax = Math.max(0, HELP_CONTENT_LINES - outputRows);
|
|
88
129
|
|
|
89
130
|
const modeRef = useRef(mode);
|
|
90
131
|
modeRef.current = mode;
|
|
@@ -198,10 +239,16 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
198
239
|
|
|
199
240
|
const filteredItems = useMemo((): MenuItem[] => {
|
|
200
241
|
if (!searchQuery) return menuItems;
|
|
201
|
-
|
|
202
|
-
|
|
242
|
+
const q = searchQuery.toLowerCase();
|
|
243
|
+
const topLevel = menuItems.filter((item) =>
|
|
244
|
+
item.label.toLowerCase().includes(q),
|
|
245
|
+
);
|
|
246
|
+
if (!hasGroups || currentGroup) return topLevel;
|
|
247
|
+
const groupCommands = activeCommands.filter(
|
|
248
|
+
(cmd) => cmd.group && cmd.label.toLowerCase().includes(q),
|
|
203
249
|
);
|
|
204
|
-
|
|
250
|
+
return groupCommands.length > 0 ? [...topLevel, ...groupCommands] : topLevel;
|
|
251
|
+
}, [menuItems, searchQuery, hasGroups, currentGroup, activeCommands]);
|
|
205
252
|
|
|
206
253
|
useEffect(() => {
|
|
207
254
|
if (selectedIndex >= filteredItems.length && filteredItems.length > 0) {
|
|
@@ -215,15 +262,17 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
215
262
|
|
|
216
263
|
const selCount = multiSelected.size;
|
|
217
264
|
const selectedItem = filteredItems[selectedIndex];
|
|
218
|
-
const footerText =
|
|
219
|
-
?
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
:
|
|
226
|
-
|
|
265
|
+
const footerText = mode === 'help'
|
|
266
|
+
? 'Press ? or Esc to close help'
|
|
267
|
+
: selectedItem
|
|
268
|
+
? isProfileOption(selectedItem)
|
|
269
|
+
? selectedItem.active
|
|
270
|
+
? `Active profile: ${selectedItem.label}`
|
|
271
|
+
: `Switch to ${selectedItem.label} profile`
|
|
272
|
+
: 'count' in selectedItem
|
|
273
|
+
? `Open ${selectedItem.label}`
|
|
274
|
+
: selectedItem.description ?? 'Enter to run, Space to select, / to search, Tab focus, ? help'
|
|
275
|
+
: 'Enter to run, Space to select, / to search, Tab focus, ? help';
|
|
227
276
|
|
|
228
277
|
const runSingle = useCallback((cmd: ProkomCommand) => {
|
|
229
278
|
if (cmd.id === 'demo-confirm-overlay') {
|
|
@@ -247,7 +296,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
247
296
|
if (task?.status === 'running') {
|
|
248
297
|
runtime.taskRunner.stop(cmd);
|
|
249
298
|
} else {
|
|
250
|
-
runtime.taskRunner.run(cmd)
|
|
299
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
300
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
301
|
+
});
|
|
251
302
|
}
|
|
252
303
|
} else if (cmd.confirm) {
|
|
253
304
|
setConfirmingCmd(cmd);
|
|
@@ -257,7 +308,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
257
308
|
setInputValue('');
|
|
258
309
|
setMode('input');
|
|
259
310
|
} else {
|
|
260
|
-
runtime.taskRunner.run(cmd)
|
|
311
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
312
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
313
|
+
});
|
|
261
314
|
}
|
|
262
315
|
}, [runtime]);
|
|
263
316
|
|
|
@@ -284,7 +337,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
284
337
|
for (const cmd of activeCommands) {
|
|
285
338
|
if (selected.has(cmd.id)) {
|
|
286
339
|
if (!cmd.confirm && !cmd.input) {
|
|
287
|
-
runtime.taskRunner.run(cmd)
|
|
340
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
341
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
342
|
+
});
|
|
288
343
|
}
|
|
289
344
|
}
|
|
290
345
|
}
|
|
@@ -362,6 +417,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
362
417
|
return;
|
|
363
418
|
}
|
|
364
419
|
|
|
420
|
+
if (modeRef.current === 'help') {
|
|
421
|
+
if (key.escape || input === '?') {
|
|
422
|
+
setHelpScroll(0);
|
|
423
|
+
setMode('normal');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
365
428
|
if (mode === 'search') {
|
|
366
429
|
if (key.escape) {
|
|
367
430
|
setSearchQuery('');
|
|
@@ -394,7 +457,28 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
394
457
|
return;
|
|
395
458
|
}
|
|
396
459
|
|
|
460
|
+
if (input === '?' && modeRef.current === 'normal') {
|
|
461
|
+
setHelpScroll(0);
|
|
462
|
+
setMode('help');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
397
466
|
if (focusedPane === 'status') {
|
|
467
|
+
if (modeRef.current === 'help') {
|
|
468
|
+
if (key.upArrow) {
|
|
469
|
+
setHelpScroll((s) => Math.max(0, s - 1));
|
|
470
|
+
} else if (key.downArrow) {
|
|
471
|
+
setHelpScroll((s) => Math.min(helpScrollMax, s + 1));
|
|
472
|
+
} else if (key.pageUp) {
|
|
473
|
+
setHelpScroll((s) => Math.max(0, s - 10));
|
|
474
|
+
} else if (key.pageDown) {
|
|
475
|
+
setHelpScroll((s) => Math.min(helpScrollMax, s + 10));
|
|
476
|
+
} else if (key.escape) {
|
|
477
|
+
setFocusedPane('commands');
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
398
482
|
if (key.escape) {
|
|
399
483
|
setFocusedPane('commands');
|
|
400
484
|
} else if (key.upArrow) {
|
|
@@ -586,16 +670,52 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
586
670
|
<Box width={1} />
|
|
587
671
|
<MetricsPanel tasks={tasks} menuRows={outputRows} width={statusPaneWidth} />
|
|
588
672
|
<Box width={1} />
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
673
|
+
{mode === 'help' ? (
|
|
674
|
+
<Panel
|
|
675
|
+
title="Help"
|
|
676
|
+
titleColor={focusedPane === 'status' ? 'cyan' : 'white'}
|
|
677
|
+
titleExtra={
|
|
678
|
+
helpScrollMax > 0
|
|
679
|
+
? <Text>{` ${helpScroll > 0 ? '▲' : ''}${helpScroll < helpScrollMax ? ' ▼' : ''}`}</Text>
|
|
680
|
+
: undefined
|
|
681
|
+
}
|
|
682
|
+
titleExtraWidth={helpScrollMax > 0 ? 4 : 0}
|
|
683
|
+
borderColor={focusedPane === 'status' ? 'cyan' : 'white'}
|
|
684
|
+
height={outputRows + 2}
|
|
685
|
+
width={outputPaneWidth}
|
|
686
|
+
>
|
|
687
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
688
|
+
{helpEntries.slice(helpScroll, helpScroll + outputRows).map((entry, i) => {
|
|
689
|
+
switch (entry.kind) {
|
|
690
|
+
case 'header':
|
|
691
|
+
return <Box key={i + helpScroll}><Text bold color="cyan">{entry.text}</Text></Box>;
|
|
692
|
+
case 'key':
|
|
693
|
+
return (
|
|
694
|
+
<Box key={i + helpScroll}>
|
|
695
|
+
<Text color="yellow"> {entry.key.padEnd(10)}</Text>
|
|
696
|
+
<Text>{entry.desc}</Text>
|
|
697
|
+
</Box>
|
|
698
|
+
);
|
|
699
|
+
case 'spacer':
|
|
700
|
+
return <Box key={i + helpScroll}><Text> </Text></Box>;
|
|
701
|
+
case 'hint':
|
|
702
|
+
return <Box key={i + helpScroll}><Text color="gray">? or Esc to close</Text></Box>;
|
|
703
|
+
}
|
|
704
|
+
})}
|
|
705
|
+
</Box>
|
|
706
|
+
</Panel>
|
|
707
|
+
) : (
|
|
708
|
+
<StatusPanel
|
|
709
|
+
tasks={tasks}
|
|
710
|
+
width={outputPaneWidth}
|
|
711
|
+
scrollOffsets={scrollOffsets}
|
|
712
|
+
focusedPane={focusedPane}
|
|
713
|
+
confirmingCommand={mode === 'confirm' ? confirmingCmd : null}
|
|
714
|
+
inputCommand={mode === 'input' ? inputCmd : null}
|
|
715
|
+
inputValue={inputValue}
|
|
716
|
+
menuRows={outputRows}
|
|
717
|
+
/>
|
|
718
|
+
)}
|
|
599
719
|
</Box>
|
|
600
720
|
|
|
601
721
|
<Box>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { CommandList } from './command-list.js';
|
|
5
|
+
|
|
6
|
+
const baseProps = {
|
|
7
|
+
items: [],
|
|
8
|
+
selectedIndex: 0,
|
|
9
|
+
width: 50,
|
|
10
|
+
menuRows: 10,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('CommandList', () => {
|
|
14
|
+
it('shows no commands configured when empty', () => {
|
|
15
|
+
const { lastFrame } = render(<CommandList {...baseProps} />);
|
|
16
|
+
expect(lastFrame()).toContain('No commands configured');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders command items', () => {
|
|
20
|
+
const items = [
|
|
21
|
+
{ id: 'build', label: 'Build', command: 'npm run build' },
|
|
22
|
+
{ id: 'test', label: 'Test', command: 'npm test' },
|
|
23
|
+
];
|
|
24
|
+
const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
|
|
25
|
+
const frame = lastFrame();
|
|
26
|
+
expect(frame).toContain('Build');
|
|
27
|
+
expect(frame).toContain('Test');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('shows cursor on selected item', () => {
|
|
31
|
+
const items = [
|
|
32
|
+
{ id: 'build', label: 'Build', command: 'npm run build' },
|
|
33
|
+
{ id: 'test', label: 'Test', command: 'npm test' },
|
|
34
|
+
];
|
|
35
|
+
const { lastFrame } = render(
|
|
36
|
+
<CommandList {...baseProps} items={items} selectedIndex={0} />,
|
|
37
|
+
);
|
|
38
|
+
expect(lastFrame()).toContain('❯');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows group headers', () => {
|
|
42
|
+
const items = [
|
|
43
|
+
{ id: 'dev', label: 'Development', count: 2 },
|
|
44
|
+
{ id: 'build', label: 'Build', command: 'npm run build' },
|
|
45
|
+
];
|
|
46
|
+
const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
|
|
47
|
+
const frame = lastFrame();
|
|
48
|
+
expect(frame).toContain('▶ Development');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders profile options', () => {
|
|
52
|
+
const items = [
|
|
53
|
+
{ kind: 'profile' as const, id: 'p1', label: 'Production', active: false },
|
|
54
|
+
];
|
|
55
|
+
const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
|
|
56
|
+
const frame = lastFrame();
|
|
57
|
+
expect(frame).toContain('Production');
|
|
58
|
+
expect(frame).toContain('○');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('shows active profile with filled circle', () => {
|
|
62
|
+
const items = [
|
|
63
|
+
{ kind: 'profile' as const, id: 'p1', label: 'Default', active: true },
|
|
64
|
+
];
|
|
65
|
+
const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
|
|
66
|
+
expect(lastFrame()).toContain('●');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('shows multi-select checkboxes', () => {
|
|
70
|
+
const items = [
|
|
71
|
+
{ id: 'a', label: 'A', command: 'echo a' },
|
|
72
|
+
{ id: 'b', label: 'B', command: 'echo b' },
|
|
73
|
+
];
|
|
74
|
+
const { lastFrame } = render(
|
|
75
|
+
<CommandList {...baseProps} items={items} multiSelected={new Set(['a'])} />,
|
|
76
|
+
);
|
|
77
|
+
expect(lastFrame()).toContain('[✓]');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows cwd badge when present', () => {
|
|
81
|
+
const items = [
|
|
82
|
+
{ id: 'build', label: 'Build', command: 'npm run build', cwd: '/tmp' },
|
|
83
|
+
];
|
|
84
|
+
const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
|
|
85
|
+
expect(lastFrame()).toContain('/tmp');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('shows pipeline badge', () => {
|
|
89
|
+
const items = [
|
|
90
|
+
{ id: 'pipe', label: 'Pipeline', command: '', pipelineSteps: ['a', 'b'] },
|
|
91
|
+
];
|
|
92
|
+
const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
|
|
93
|
+
expect(lastFrame()).toContain('pipeline');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('shows watch badge', () => {
|
|
97
|
+
const items = [
|
|
98
|
+
{ id: 'w', label: 'Watch', command: 'npm run dev', watch: true },
|
|
99
|
+
];
|
|
100
|
+
const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
|
|
101
|
+
expect(lastFrame()).toContain('watch');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('shows focused border color when focused', () => {
|
|
105
|
+
const items = [
|
|
106
|
+
{ id: 'build', label: 'Build', command: 'npm run build' },
|
|
107
|
+
];
|
|
108
|
+
const { lastFrame } = render(
|
|
109
|
+
<CommandList {...baseProps} items={items} focused={true} />,
|
|
110
|
+
);
|
|
111
|
+
expect(lastFrame()).toContain('Commands');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('shows selection count in title', () => {
|
|
115
|
+
const items = [
|
|
116
|
+
{ id: 'a', label: 'A', command: 'echo a' },
|
|
117
|
+
{ id: 'b', label: 'B', command: 'echo b' },
|
|
118
|
+
];
|
|
119
|
+
const { lastFrame } = render(
|
|
120
|
+
<CommandList {...baseProps} items={items} selCount={2} multiSelected={new Set(['a', 'b'])} />,
|
|
121
|
+
);
|
|
122
|
+
expect(lastFrame()).toContain('(2 selected)');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { MetricsPanel } from './metrics-panel.js';
|
|
6
|
+
|
|
7
|
+
function makeMetricsData(overrides: Record<string, any> = {}) {
|
|
8
|
+
return JSON.stringify({
|
|
9
|
+
version: '1.0.0',
|
|
10
|
+
latestTest: { label: 'Unit Tests', status: 'success', time: Date.now() - 60000 },
|
|
11
|
+
latestBuild: { status: 'failure', time: Date.now() - 120000 },
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
vi.mock('fs', () => ({
|
|
17
|
+
default: {
|
|
18
|
+
readFileSync: vi.fn(() => makeMetricsData()),
|
|
19
|
+
writeFileSync: vi.fn(),
|
|
20
|
+
mkdirSync: vi.fn(),
|
|
21
|
+
existsSync: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
readFileSync: vi.fn(() => makeMetricsData()),
|
|
24
|
+
writeFileSync: vi.fn(),
|
|
25
|
+
mkdirSync: vi.fn(),
|
|
26
|
+
existsSync: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const mockReadFileSync = () => vi.mocked(fs).readFileSync;
|
|
30
|
+
|
|
31
|
+
const mockTasks = (entries: Record<string, any>): Map<string, any> =>
|
|
32
|
+
new Map(Object.entries(entries)) as Map<string, any>;
|
|
33
|
+
|
|
34
|
+
describe('MetricsPanel', () => {
|
|
35
|
+
const baseProps = {
|
|
36
|
+
tasks: new Map(),
|
|
37
|
+
width: 28,
|
|
38
|
+
menuRows: 10,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mockReadFileSync().mockReturnValue(makeMetricsData());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('shows idle status when no tasks are running', () => {
|
|
46
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
47
|
+
expect(lastFrame()).toContain('idle');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('displays the package version', () => {
|
|
51
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
52
|
+
expect(lastFrame()).toContain('Version: 1.0.0');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('shows active task count for running tasks', async () => {
|
|
56
|
+
const tasks = mockTasks({
|
|
57
|
+
build: { id: 'build', label: 'Build', status: 'running', startTime: Date.now() },
|
|
58
|
+
test: { id: 'test', label: 'Test', status: 'running', startTime: Date.now() },
|
|
59
|
+
});
|
|
60
|
+
const { lastFrame, rerender } = render(<MetricsPanel {...baseProps} />);
|
|
61
|
+
await act(async () => {
|
|
62
|
+
rerender(<MetricsPanel {...baseProps} tasks={tasks} />);
|
|
63
|
+
});
|
|
64
|
+
const frame = lastFrame();
|
|
65
|
+
expect(frame).toContain('Active: 2');
|
|
66
|
+
expect(frame).toContain('running');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('shows test passed status with time ago', () => {
|
|
70
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
71
|
+
const frame = lastFrame();
|
|
72
|
+
expect(frame).toContain('Tests: passed');
|
|
73
|
+
expect(frame).toContain('1m ago');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('shows test failure status', () => {
|
|
77
|
+
mockReadFileSync().mockReturnValue(makeMetricsData({
|
|
78
|
+
latestTest: { label: 'Tests', status: 'failure', time: Date.now() - 30000 },
|
|
79
|
+
}));
|
|
80
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
81
|
+
expect(lastFrame()).toContain('Tests: failed');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('shows no test run when no test data', () => {
|
|
85
|
+
mockReadFileSync().mockReturnValue(makeMetricsData({ latestTest: undefined }));
|
|
86
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
87
|
+
expect(lastFrame()).toContain('Tests: no run');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('shows build failed status', () => {
|
|
91
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
92
|
+
expect(lastFrame()).toContain('Build: failed');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('shows build passed status', () => {
|
|
96
|
+
mockReadFileSync().mockReturnValue(makeMetricsData({
|
|
97
|
+
latestBuild: { status: 'success', time: Date.now() - 300000 },
|
|
98
|
+
}));
|
|
99
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
100
|
+
expect(lastFrame()).toContain('Build: passed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shows no build run when no build data', () => {
|
|
104
|
+
mockReadFileSync().mockReturnValue(makeMetricsData({ latestBuild: undefined }));
|
|
105
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
106
|
+
expect(lastFrame()).toContain('Build: no run');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('shows last git push timestamp', () => {
|
|
110
|
+
const pushTime = new Date('2026-06-24T10:00:00').getTime();
|
|
111
|
+
mockReadFileSync().mockReturnValue(makeMetricsData({
|
|
112
|
+
lastGitPush: { label: 'main', time: pushTime },
|
|
113
|
+
}));
|
|
114
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
115
|
+
expect(lastFrame()).toContain('2026-06-24 10:00');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('shows push dash when no push data', () => {
|
|
119
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
120
|
+
expect(lastFrame()).toContain('Push: -');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('shows version dash when package.json is missing', () => {
|
|
124
|
+
mockReadFileSync().mockImplementation(() => { throw new Error('ENOENT'); });
|
|
125
|
+
const { lastFrame } = render(<MetricsPanel {...baseProps} />);
|
|
126
|
+
expect(lastFrame()).toContain('Version: -');
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/ui/metrics-panel.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import React, { useEffect, useState } from 'react';
|
|
4
4
|
import { Box, Text } from 'ink';
|
|
5
5
|
import { TaskState, TaskStatus } from '../status/types.js';
|
|
6
|
+
import { PERSISTENCE_DIR } from '../core/persistence.js';
|
|
6
7
|
import { Panel } from './panel.js';
|
|
7
8
|
|
|
8
9
|
interface MetricsPanelProps {
|
|
@@ -10,7 +11,7 @@ interface MetricsPanelProps {
|
|
|
10
11
|
menuRows: number;
|
|
11
12
|
width: number;
|
|
12
13
|
}
|
|
13
|
-
const METRICS_FILE = path.join(process.cwd(),
|
|
14
|
+
const METRICS_FILE = path.join(process.cwd(), PERSISTENCE_DIR, 'metrics.json');
|
|
14
15
|
|
|
15
16
|
interface MetricsState {
|
|
16
17
|
projectRunning: boolean;
|
|
@@ -31,12 +32,6 @@ interface MetricsState {
|
|
|
31
32
|
packageVersion: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
const INITIAL_METRICS: MetricsState = {
|
|
35
|
-
projectRunning: false,
|
|
36
|
-
activeTasks: 0,
|
|
37
|
-
packageVersion: loadPackageVersion(),
|
|
38
|
-
};
|
|
39
|
-
|
|
40
35
|
function loadPackageVersion(): string {
|
|
41
36
|
try {
|
|
42
37
|
const packageJson = JSON.parse(
|
|
@@ -49,16 +44,21 @@ function loadPackageVersion(): string {
|
|
|
49
44
|
}
|
|
50
45
|
|
|
51
46
|
function loadMetrics(): MetricsState {
|
|
47
|
+
const base = {
|
|
48
|
+
projectRunning: false,
|
|
49
|
+
activeTasks: 0,
|
|
50
|
+
packageVersion: loadPackageVersion(),
|
|
51
|
+
};
|
|
52
52
|
try {
|
|
53
53
|
const persisted = JSON.parse(fs.readFileSync(METRICS_FILE, 'utf-8')) as MetricsState;
|
|
54
54
|
return {
|
|
55
|
-
...
|
|
55
|
+
...base,
|
|
56
56
|
latestTest: persisted.latestTest,
|
|
57
57
|
latestBuild: persisted.latestBuild,
|
|
58
58
|
lastGitPush: persisted.lastGitPush,
|
|
59
59
|
};
|
|
60
60
|
} catch {
|
|
61
|
-
return
|
|
61
|
+
return base;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -169,7 +169,7 @@ export const MetricsPanel: React.FC<MetricsPanelProps> = ({ tasks, menuRows, wid
|
|
|
169
169
|
}, [tasks]);
|
|
170
170
|
|
|
171
171
|
return (
|
|
172
|
-
<Panel title="Status" width={width} height={menuRows + 2}>
|
|
172
|
+
<Panel title="Status" titleColor="white" width={width} height={menuRows + 2}>
|
|
173
173
|
<Box paddingLeft={1}>
|
|
174
174
|
<Text color={metrics.projectRunning ? 'yellow' : 'gray'}>
|
|
175
175
|
Project: {metrics.projectRunning ? 'running' : 'idle'}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { render } from 'ink-testing-library';
|
|
5
|
+
import { Panel } from './panel.js';
|
|
6
|
+
|
|
7
|
+
describe('Panel', () => {
|
|
8
|
+
it('renders the title in the top border', () => {
|
|
9
|
+
const { lastFrame } = render(
|
|
10
|
+
<Panel title="Test" width={20} height={5}>
|
|
11
|
+
<React.Fragment />
|
|
12
|
+
</Panel>,
|
|
13
|
+
);
|
|
14
|
+
const frame = lastFrame();
|
|
15
|
+
expect(frame).toContain('╭─ Test');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders proper bottom border', () => {
|
|
19
|
+
const { lastFrame } = render(
|
|
20
|
+
<Panel title="Test" width={20} height={5}>
|
|
21
|
+
<React.Fragment />
|
|
22
|
+
</Panel>,
|
|
23
|
+
);
|
|
24
|
+
const frame = lastFrame();
|
|
25
|
+
expect(frame).toContain('╰');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders side borders for each content row', () => {
|
|
29
|
+
const { lastFrame } = render(
|
|
30
|
+
<Panel title="Test" width={20} height={5}>
|
|
31
|
+
<React.Fragment />
|
|
32
|
+
</Panel>,
|
|
33
|
+
);
|
|
34
|
+
const frame = lastFrame()!;
|
|
35
|
+
const lines = frame.split('\n');
|
|
36
|
+
expect(lines.length).toBe(5);
|
|
37
|
+
expect(lines[1].startsWith('│')).toBe(true);
|
|
38
|
+
expect(lines[1].endsWith('│')).toBe(true);
|
|
39
|
+
expect(lines[2].startsWith('│')).toBe(true);
|
|
40
|
+
expect(lines[2].endsWith('│')).toBe(true);
|
|
41
|
+
expect(lines[3].startsWith('│')).toBe(true);
|
|
42
|
+
expect(lines[3].endsWith('│')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders children in the content area', () => {
|
|
46
|
+
const { lastFrame } = render(
|
|
47
|
+
<Panel title="Test" width={20} height={5}>
|
|
48
|
+
<Box><Text>hello</Text></Box>
|
|
49
|
+
</Panel>,
|
|
50
|
+
);
|
|
51
|
+
const frame = lastFrame();
|
|
52
|
+
expect(frame).toContain('hello');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('shows "↑N more" when hiddenAbove is set', () => {
|
|
56
|
+
const { lastFrame } = render(
|
|
57
|
+
<Panel title="Test" width={30} height={5} hiddenAbove={3}>
|
|
58
|
+
<React.Fragment />
|
|
59
|
+
</Panel>,
|
|
60
|
+
);
|
|
61
|
+
const frame = lastFrame();
|
|
62
|
+
expect(frame).toContain('↑3 more');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shows "↓N more" when hiddenBelow is set', () => {
|
|
66
|
+
const { lastFrame } = render(
|
|
67
|
+
<Panel title="Test" width={30} height={5} hiddenBelow={2}>
|
|
68
|
+
<React.Fragment />
|
|
69
|
+
</Panel>,
|
|
70
|
+
);
|
|
71
|
+
const frame = lastFrame();
|
|
72
|
+
expect(frame).toContain('↓2 more');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders titleExtra next to the title', () => {
|
|
76
|
+
const { lastFrame } = render(
|
|
77
|
+
<Panel title="Main" width={30} height={5} titleExtra={<Text> [extra]</Text>}>
|
|
78
|
+
<React.Fragment />
|
|
79
|
+
</Panel>,
|
|
80
|
+
);
|
|
81
|
+
const frame = lastFrame();
|
|
82
|
+
expect(frame).toContain('[extra]');
|
|
83
|
+
});
|
|
84
|
+
});
|