@hartvig/developer-control-center 0.8.6 → 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 +15 -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 +2 -2
- 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 +97 -16
- 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 +139 -27
- 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,8 +97,11 @@ 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
107
|
const statusPaneWidth = 28;
|
|
@@ -93,6 +125,7 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
93
125
|
}, [activeCommands, terminalColumns, statusPaneWidth]);
|
|
94
126
|
|
|
95
127
|
const outputPaneWidth = Math.max(20, terminalColumns - commandPaneWidth - statusPaneWidth - 4);
|
|
128
|
+
const helpScrollMax = Math.max(0, HELP_CONTENT_LINES - outputRows);
|
|
96
129
|
|
|
97
130
|
const modeRef = useRef(mode);
|
|
98
131
|
modeRef.current = mode;
|
|
@@ -206,10 +239,16 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
206
239
|
|
|
207
240
|
const filteredItems = useMemo((): MenuItem[] => {
|
|
208
241
|
if (!searchQuery) return menuItems;
|
|
209
|
-
|
|
210
|
-
|
|
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),
|
|
211
249
|
);
|
|
212
|
-
|
|
250
|
+
return groupCommands.length > 0 ? [...topLevel, ...groupCommands] : topLevel;
|
|
251
|
+
}, [menuItems, searchQuery, hasGroups, currentGroup, activeCommands]);
|
|
213
252
|
|
|
214
253
|
useEffect(() => {
|
|
215
254
|
if (selectedIndex >= filteredItems.length && filteredItems.length > 0) {
|
|
@@ -223,15 +262,17 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
223
262
|
|
|
224
263
|
const selCount = multiSelected.size;
|
|
225
264
|
const selectedItem = filteredItems[selectedIndex];
|
|
226
|
-
const footerText =
|
|
227
|
-
?
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
:
|
|
234
|
-
|
|
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';
|
|
235
276
|
|
|
236
277
|
const runSingle = useCallback((cmd: ProkomCommand) => {
|
|
237
278
|
if (cmd.id === 'demo-confirm-overlay') {
|
|
@@ -255,7 +296,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
255
296
|
if (task?.status === 'running') {
|
|
256
297
|
runtime.taskRunner.stop(cmd);
|
|
257
298
|
} else {
|
|
258
|
-
runtime.taskRunner.run(cmd)
|
|
299
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
300
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
301
|
+
});
|
|
259
302
|
}
|
|
260
303
|
} else if (cmd.confirm) {
|
|
261
304
|
setConfirmingCmd(cmd);
|
|
@@ -265,7 +308,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
265
308
|
setInputValue('');
|
|
266
309
|
setMode('input');
|
|
267
310
|
} else {
|
|
268
|
-
runtime.taskRunner.run(cmd)
|
|
311
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
312
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
313
|
+
});
|
|
269
314
|
}
|
|
270
315
|
}, [runtime]);
|
|
271
316
|
|
|
@@ -292,7 +337,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
292
337
|
for (const cmd of activeCommands) {
|
|
293
338
|
if (selected.has(cmd.id)) {
|
|
294
339
|
if (!cmd.confirm && !cmd.input) {
|
|
295
|
-
runtime.taskRunner.run(cmd)
|
|
340
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
341
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
342
|
+
});
|
|
296
343
|
}
|
|
297
344
|
}
|
|
298
345
|
}
|
|
@@ -370,6 +417,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
370
417
|
return;
|
|
371
418
|
}
|
|
372
419
|
|
|
420
|
+
if (modeRef.current === 'help') {
|
|
421
|
+
if (key.escape || input === '?') {
|
|
422
|
+
setHelpScroll(0);
|
|
423
|
+
setMode('normal');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
373
428
|
if (mode === 'search') {
|
|
374
429
|
if (key.escape) {
|
|
375
430
|
setSearchQuery('');
|
|
@@ -402,7 +457,28 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
402
457
|
return;
|
|
403
458
|
}
|
|
404
459
|
|
|
460
|
+
if (input === '?' && modeRef.current === 'normal') {
|
|
461
|
+
setHelpScroll(0);
|
|
462
|
+
setMode('help');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
405
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
|
+
|
|
406
482
|
if (key.escape) {
|
|
407
483
|
setFocusedPane('commands');
|
|
408
484
|
} else if (key.upArrow) {
|
|
@@ -594,16 +670,52 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
594
670
|
<Box width={1} />
|
|
595
671
|
<MetricsPanel tasks={tasks} menuRows={outputRows} width={statusPaneWidth} />
|
|
596
672
|
<Box width={1} />
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
+
)}
|
|
607
719
|
</Box>
|
|
608
720
|
|
|
609
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
|
+
});
|