@hartvig/developer-control-center 0.8.6 → 0.8.8
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 +25 -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 +1 -1
- 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 -3
- 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 +81 -24
- 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 +124 -30
- package/dist/ui/app.js.map +1 -1
- package/dist/ui/app.test.d.ts +2 -0
- package/dist/ui/app.test.d.ts.map +1 -0
- package/dist/ui/app.test.js +157 -0
- package/dist/ui/app.test.js.map +1 -0
- 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 +4 -2
- package/src/cli.ts +1 -1
- package/src/core/persistence.ts +1 -0
- package/src/core/runtime.ts +7 -3
- package/src/core/task-runner.test.ts +395 -0
- package/src/core/task-runner.ts +80 -24
- 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.test.tsx +177 -0
- package/src/ui/app.tsx +167 -41
- 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
|
@@ -3,6 +3,7 @@ import { Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
|
3
3
|
import { Runtime } from '../core/index.js';
|
|
4
4
|
import { ProkomConfig, ProkomCommand, mergeCommands } from '../config/index.js';
|
|
5
5
|
import { TaskState } from '../status/types.js';
|
|
6
|
+
import { sendNotification } from '../core/notifier.js';
|
|
6
7
|
import { CommandList, MenuGroup, MenuItem, ProfileOption } from './command-list.js';
|
|
7
8
|
import { MetricsPanel } from './metrics-panel.js';
|
|
8
9
|
import { Panel } from './panel.js';
|
|
@@ -13,7 +14,7 @@ interface AppProps {
|
|
|
13
14
|
runtime: Runtime;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup';
|
|
17
|
+
type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup' | 'help';
|
|
17
18
|
|
|
18
19
|
interface PopupState {
|
|
19
20
|
title: string;
|
|
@@ -26,6 +27,35 @@ type Pane = 'commands' | 'status';
|
|
|
26
27
|
const PROFILE_GROUP_ID = '__profiles';
|
|
27
28
|
const GROUP_ORDER = ['Development', 'Build', 'Deploy', 'Management', 'Demo'];
|
|
28
29
|
|
|
30
|
+
type HelpEntry =
|
|
31
|
+
| { kind: 'header'; text: string }
|
|
32
|
+
| { kind: 'key'; key: string; desc: string }
|
|
33
|
+
| { kind: 'spacer' }
|
|
34
|
+
| { kind: 'hint' };
|
|
35
|
+
|
|
36
|
+
const helpEntries: HelpEntry[] = [
|
|
37
|
+
{ kind: 'header', text: 'Navigation' },
|
|
38
|
+
{ kind: 'key', key: '↑/↓', desc: 'Navigate commands' },
|
|
39
|
+
{ kind: 'key', key: 'Enter', desc: 'Run selected command' },
|
|
40
|
+
{ kind: 'key', key: 'Space', desc: 'Multi-select / open group' },
|
|
41
|
+
{ kind: 'key', key: 'Tab', desc: 'Toggle pane focus' },
|
|
42
|
+
{ kind: 'key', key: '/', desc: 'Search/filter commands' },
|
|
43
|
+
{ kind: 'spacer' },
|
|
44
|
+
{ kind: 'header', text: 'Output pane' },
|
|
45
|
+
{ kind: 'key', key: '↑/↓', desc: 'Scroll line by line' },
|
|
46
|
+
{ kind: 'key', key: 'PgUp/PgDn', desc: 'Scroll 10 lines' },
|
|
47
|
+
{ kind: 'key', key: 'Esc', desc: 'Back to commands' },
|
|
48
|
+
{ kind: 'spacer' },
|
|
49
|
+
{ kind: 'header', text: 'Global' },
|
|
50
|
+
{ kind: 'key', key: '?', desc: 'Toggle this help' },
|
|
51
|
+
{ kind: 'key', key: 'Esc', desc: 'Back / deselect / quit' },
|
|
52
|
+
{ kind: 'key', key: 'Ctrl+C', desc: 'Quit' },
|
|
53
|
+
{ kind: 'spacer' },
|
|
54
|
+
{ kind: 'hint' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const HELP_CONTENT_LINES = helpEntries.length;
|
|
58
|
+
|
|
29
59
|
function commandsForProfile(config: ProkomConfig, profile?: string): ProkomCommand[] {
|
|
30
60
|
const base = config.baseCommands ?? config.commands;
|
|
31
61
|
const profileCommands = profile ? (config.profiles?.[profile]?.commands ?? []) : [];
|
|
@@ -68,10 +98,22 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
68
98
|
);
|
|
69
99
|
const [currentGroup, setCurrentGroup] = useState<string | null>(null);
|
|
70
100
|
const [multiSelected, setMultiSelected] = useState<Set<string>>(new Set());
|
|
101
|
+
const [helpScroll, setHelpScroll] = useState(0);
|
|
71
102
|
const [activeProfile, setActiveProfile] = useState<string | undefined>(config.profile);
|
|
72
|
-
const
|
|
103
|
+
const [termSize, setTermSize] = useState({ rows: stdout?.rows ?? 24, cols: stdout?.columns ?? 120 });
|
|
104
|
+
const terminalRows = termSize.rows;
|
|
105
|
+
const dynamicRows = Math.max(4, terminalRows - 7);
|
|
106
|
+
const menuRows = config.menuRows ?? dynamicRows;
|
|
73
107
|
const outputRows = config.outputRows ?? menuRows;
|
|
74
|
-
const terminalColumns =
|
|
108
|
+
const terminalColumns = termSize.cols;
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const onResize = () => {
|
|
112
|
+
setTermSize({ rows: stdout?.rows ?? 24, cols: stdout?.columns ?? 120 });
|
|
113
|
+
};
|
|
114
|
+
stdout?.on?.('resize', onResize);
|
|
115
|
+
return () => { stdout?.off?.('resize', onResize); };
|
|
116
|
+
}, [stdout]);
|
|
75
117
|
const statusPaneWidth = 28;
|
|
76
118
|
|
|
77
119
|
const activeCommands = useMemo(
|
|
@@ -93,6 +135,7 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
93
135
|
}, [activeCommands, terminalColumns, statusPaneWidth]);
|
|
94
136
|
|
|
95
137
|
const outputPaneWidth = Math.max(20, terminalColumns - commandPaneWidth - statusPaneWidth - 4);
|
|
138
|
+
const helpScrollMax = Math.max(0, HELP_CONTENT_LINES - outputRows);
|
|
96
139
|
|
|
97
140
|
const modeRef = useRef(mode);
|
|
98
141
|
modeRef.current = mode;
|
|
@@ -111,8 +154,12 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
111
154
|
|
|
112
155
|
useEffect(() => {
|
|
113
156
|
const onComplete = (id: string, exitCode: number | null) => {
|
|
157
|
+
const cmd = commandsRef.current.find((c) => c.id === id);
|
|
158
|
+
if (cmd) {
|
|
159
|
+
const status = exitCode === null ? 'Stopped' : exitCode === 0 ? 'Completed' : 'Failed';
|
|
160
|
+
sendNotification(`DCC: ${cmd.label}`, status);
|
|
161
|
+
}
|
|
114
162
|
if (exitCode != null && exitCode > 0) {
|
|
115
|
-
const cmd = commandsRef.current.find((c) => c.id === id);
|
|
116
163
|
if (cmd?.onNonZeroExit) {
|
|
117
164
|
setConfirmingCmd({
|
|
118
165
|
id: `${cmd.id}:on-nonzero`,
|
|
@@ -206,10 +253,16 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
206
253
|
|
|
207
254
|
const filteredItems = useMemo((): MenuItem[] => {
|
|
208
255
|
if (!searchQuery) return menuItems;
|
|
209
|
-
|
|
210
|
-
|
|
256
|
+
const q = searchQuery.toLowerCase();
|
|
257
|
+
const topLevel = menuItems.filter((item) =>
|
|
258
|
+
item.label.toLowerCase().includes(q),
|
|
259
|
+
);
|
|
260
|
+
if (!hasGroups || currentGroup) return topLevel;
|
|
261
|
+
const groupCommands = activeCommands.filter(
|
|
262
|
+
(cmd) => cmd.group && cmd.label.toLowerCase().includes(q),
|
|
211
263
|
);
|
|
212
|
-
|
|
264
|
+
return groupCommands.length > 0 ? [...topLevel, ...groupCommands] : topLevel;
|
|
265
|
+
}, [menuItems, searchQuery, hasGroups, currentGroup, activeCommands]);
|
|
213
266
|
|
|
214
267
|
useEffect(() => {
|
|
215
268
|
if (selectedIndex >= filteredItems.length && filteredItems.length > 0) {
|
|
@@ -223,15 +276,17 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
223
276
|
|
|
224
277
|
const selCount = multiSelected.size;
|
|
225
278
|
const selectedItem = filteredItems[selectedIndex];
|
|
226
|
-
const footerText =
|
|
227
|
-
?
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
:
|
|
234
|
-
|
|
279
|
+
const footerText = mode === 'help'
|
|
280
|
+
? 'Press ? or Esc to close help'
|
|
281
|
+
: selectedItem
|
|
282
|
+
? isProfileOption(selectedItem)
|
|
283
|
+
? selectedItem.active
|
|
284
|
+
? `Active profile: ${selectedItem.label}`
|
|
285
|
+
: `Switch to ${selectedItem.label} profile`
|
|
286
|
+
: 'count' in selectedItem
|
|
287
|
+
? `Open ${selectedItem.label}`
|
|
288
|
+
: selectedItem.description ?? 'Enter to run, Space to select, / to search, Tab focus, ? help'
|
|
289
|
+
: 'Enter to run, Space to select, / to search, Tab focus, ? help';
|
|
235
290
|
|
|
236
291
|
const runSingle = useCallback((cmd: ProkomCommand) => {
|
|
237
292
|
if (cmd.id === 'demo-confirm-overlay') {
|
|
@@ -255,7 +310,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
255
310
|
if (task?.status === 'running') {
|
|
256
311
|
runtime.taskRunner.stop(cmd);
|
|
257
312
|
} else {
|
|
258
|
-
runtime.taskRunner.run(cmd)
|
|
313
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
314
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
315
|
+
});
|
|
259
316
|
}
|
|
260
317
|
} else if (cmd.confirm) {
|
|
261
318
|
setConfirmingCmd(cmd);
|
|
@@ -265,7 +322,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
265
322
|
setInputValue('');
|
|
266
323
|
setMode('input');
|
|
267
324
|
} else {
|
|
268
|
-
runtime.taskRunner.run(cmd)
|
|
325
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
326
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
327
|
+
});
|
|
269
328
|
}
|
|
270
329
|
}, [runtime]);
|
|
271
330
|
|
|
@@ -292,7 +351,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
292
351
|
for (const cmd of activeCommands) {
|
|
293
352
|
if (selected.has(cmd.id)) {
|
|
294
353
|
if (!cmd.confirm && !cmd.input) {
|
|
295
|
-
runtime.taskRunner.run(cmd)
|
|
354
|
+
runtime.taskRunner.run(cmd).catch((e: unknown) => {
|
|
355
|
+
process.stderr.write(`[dcc] run error: ${e}\n`);
|
|
356
|
+
});
|
|
296
357
|
}
|
|
297
358
|
}
|
|
298
359
|
}
|
|
@@ -370,6 +431,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
370
431
|
return;
|
|
371
432
|
}
|
|
372
433
|
|
|
434
|
+
if (modeRef.current === 'help') {
|
|
435
|
+
if (key.escape || input === '?') {
|
|
436
|
+
setHelpScroll(0);
|
|
437
|
+
setMode('normal');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
373
442
|
if (mode === 'search') {
|
|
374
443
|
if (key.escape) {
|
|
375
444
|
setSearchQuery('');
|
|
@@ -402,58 +471,79 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
402
471
|
return;
|
|
403
472
|
}
|
|
404
473
|
|
|
474
|
+
if (input === '?' && modeRef.current === 'normal') {
|
|
475
|
+
setHelpScroll(0);
|
|
476
|
+
setMode('help');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
405
480
|
if (focusedPane === 'status') {
|
|
481
|
+
if (modeRef.current === 'help') {
|
|
482
|
+
if (key.upArrow) {
|
|
483
|
+
setHelpScroll((s) => Math.max(0, s - 1));
|
|
484
|
+
} else if (key.downArrow) {
|
|
485
|
+
setHelpScroll((s) => Math.min(helpScrollMax, s + 1));
|
|
486
|
+
} else if (key.pageUp) {
|
|
487
|
+
setHelpScroll((s) => Math.max(0, s - 10));
|
|
488
|
+
} else if (key.pageDown) {
|
|
489
|
+
setHelpScroll((s) => Math.min(helpScrollMax, s + 10));
|
|
490
|
+
} else if (key.escape) {
|
|
491
|
+
setFocusedPane('commands');
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
406
496
|
if (key.escape) {
|
|
407
497
|
setFocusedPane('commands');
|
|
408
498
|
} else if (key.upArrow) {
|
|
409
499
|
setScrollOffsets((prev) => {
|
|
410
500
|
const next = new Map(prev);
|
|
411
|
-
const entries = Array.from(
|
|
501
|
+
const entries = Array.from(tasksRef.current.values()).sort(
|
|
412
502
|
(a, b) => (b.startTime || 0) - (a.startTime || 0),
|
|
413
503
|
);
|
|
414
504
|
const target = entries[0];
|
|
415
505
|
if (target) {
|
|
416
|
-
const
|
|
417
|
-
next.set(target.id,
|
|
506
|
+
const hiddenFromBottom = next.get(target.id) ?? 0;
|
|
507
|
+
next.set(target.id, hiddenFromBottom + 1);
|
|
418
508
|
}
|
|
419
509
|
return next;
|
|
420
510
|
});
|
|
421
511
|
} else if (key.downArrow) {
|
|
422
512
|
setScrollOffsets((prev) => {
|
|
423
513
|
const next = new Map(prev);
|
|
424
|
-
const entries = Array.from(
|
|
514
|
+
const entries = Array.from(tasksRef.current.values()).sort(
|
|
425
515
|
(a, b) => (b.startTime || 0) - (a.startTime || 0),
|
|
426
516
|
);
|
|
427
517
|
const target = entries[0];
|
|
428
518
|
if (target) {
|
|
429
|
-
const
|
|
430
|
-
next.set(target.id, Math.max(0,
|
|
519
|
+
const hiddenFromBottom = next.get(target.id) ?? 0;
|
|
520
|
+
next.set(target.id, Math.max(0, hiddenFromBottom - 1));
|
|
431
521
|
}
|
|
432
522
|
return next;
|
|
433
523
|
});
|
|
434
524
|
} else if (key.pageUp) {
|
|
435
525
|
setScrollOffsets((prev) => {
|
|
436
526
|
const next = new Map(prev);
|
|
437
|
-
const entries = Array.from(
|
|
527
|
+
const entries = Array.from(tasksRef.current.values()).sort(
|
|
438
528
|
(a, b) => (b.startTime || 0) - (a.startTime || 0),
|
|
439
529
|
);
|
|
440
530
|
const target = entries[0];
|
|
441
531
|
if (target) {
|
|
442
|
-
const
|
|
443
|
-
next.set(target.id,
|
|
532
|
+
const hiddenFromBottom = next.get(target.id) ?? 0;
|
|
533
|
+
next.set(target.id, hiddenFromBottom + 10);
|
|
444
534
|
}
|
|
445
535
|
return next;
|
|
446
536
|
});
|
|
447
537
|
} else if (key.pageDown) {
|
|
448
538
|
setScrollOffsets((prev) => {
|
|
449
539
|
const next = new Map(prev);
|
|
450
|
-
const entries = Array.from(
|
|
540
|
+
const entries = Array.from(tasksRef.current.values()).sort(
|
|
451
541
|
(a, b) => (b.startTime || 0) - (a.startTime || 0),
|
|
452
542
|
);
|
|
453
543
|
const target = entries[0];
|
|
454
544
|
if (target) {
|
|
455
|
-
const
|
|
456
|
-
next.set(target.id, Math.max(0,
|
|
545
|
+
const hiddenFromBottom = next.get(target.id) ?? 0;
|
|
546
|
+
next.set(target.id, Math.max(0, hiddenFromBottom - 10));
|
|
457
547
|
}
|
|
458
548
|
return next;
|
|
459
549
|
});
|
|
@@ -594,16 +684,52 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
|
|
|
594
684
|
<Box width={1} />
|
|
595
685
|
<MetricsPanel tasks={tasks} menuRows={outputRows} width={statusPaneWidth} />
|
|
596
686
|
<Box width={1} />
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
687
|
+
{mode === 'help' ? (
|
|
688
|
+
<Panel
|
|
689
|
+
title="Help"
|
|
690
|
+
titleColor={focusedPane === 'status' ? 'cyan' : 'white'}
|
|
691
|
+
titleExtra={
|
|
692
|
+
helpScrollMax > 0
|
|
693
|
+
? <Text>{` ${helpScroll > 0 ? '▲' : ''}${helpScroll < helpScrollMax ? ' ▼' : ''}`}</Text>
|
|
694
|
+
: undefined
|
|
695
|
+
}
|
|
696
|
+
titleExtraWidth={helpScrollMax > 0 ? 4 : 0}
|
|
697
|
+
borderColor={focusedPane === 'status' ? 'cyan' : 'white'}
|
|
698
|
+
height={outputRows + 2}
|
|
699
|
+
width={outputPaneWidth}
|
|
700
|
+
>
|
|
701
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
702
|
+
{helpEntries.slice(helpScroll, helpScroll + outputRows).map((entry, i) => {
|
|
703
|
+
switch (entry.kind) {
|
|
704
|
+
case 'header':
|
|
705
|
+
return <Box key={i + helpScroll}><Text bold color="cyan">{entry.text}</Text></Box>;
|
|
706
|
+
case 'key':
|
|
707
|
+
return (
|
|
708
|
+
<Box key={i + helpScroll}>
|
|
709
|
+
<Text color="yellow"> {entry.key.padEnd(10)}</Text>
|
|
710
|
+
<Text>{entry.desc}</Text>
|
|
711
|
+
</Box>
|
|
712
|
+
);
|
|
713
|
+
case 'spacer':
|
|
714
|
+
return <Box key={i + helpScroll}><Text> </Text></Box>;
|
|
715
|
+
case 'hint':
|
|
716
|
+
return <Box key={i + helpScroll}><Text color="gray">? or Esc to close</Text></Box>;
|
|
717
|
+
}
|
|
718
|
+
})}
|
|
719
|
+
</Box>
|
|
720
|
+
</Panel>
|
|
721
|
+
) : (
|
|
722
|
+
<StatusPanel
|
|
723
|
+
tasks={tasks}
|
|
724
|
+
width={outputPaneWidth}
|
|
725
|
+
scrollOffsets={scrollOffsets}
|
|
726
|
+
focusedPane={focusedPane}
|
|
727
|
+
confirmingCommand={mode === 'confirm' ? confirmingCmd : null}
|
|
728
|
+
inputCommand={mode === 'input' ? inputCmd : null}
|
|
729
|
+
inputValue={inputValue}
|
|
730
|
+
menuRows={outputRows}
|
|
731
|
+
/>
|
|
732
|
+
)}
|
|
607
733
|
</Box>
|
|
608
734
|
|
|
609
735
|
<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'}
|