bgrun 3.11.0 → 3.12.0

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/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  **Production-ready process manager with dashboard and programmatic API, designed for running your containers, services, and AI agents.**
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/bgrun?color=F7A41D&label=npm&logo=npm)](https://www.npmjs.com/package/bgrun)
8
+ [![CI](https://github.com/Mements/bgr/actions/workflows/ci.yml/badge.svg)](https://github.com/Mements/bgr/actions/workflows/ci.yml)
8
9
  [![bun](https://img.shields.io/badge/runtime-bun-F7A41D?logo=bun)](https://bun.sh/)
9
10
  [![license](https://img.shields.io/npm/l/bgrun)](./LICENSE)
10
11
 
@@ -74,11 +75,16 @@ bgrun.yourdomain.com {
74
75
  Features:
75
76
  - Real-time process status via SSE (no polling)
76
77
  - Start, stop, restart, and delete processes from the UI
77
- - Live stdout/stderr log viewer with search
78
+ - Live stdout/stderr log viewer with search and virtual scrolling
78
79
  - Memory, PID, port, runtime, and guard restarts at a glance
79
80
  - Guard toggle per-process (auto-restart on crash)
80
- - Responsive mobile layout
81
+ - Keyboard shortcuts — `↑/↓` or `j/k` navigate, `Enter` open, `R` restart, `S` stop, `G` guard, `D` delete, `N` new, `?` help
82
+ - Search with debounce, result count badge, and persistence across SSE updates
83
+ - Auto-calibrated virtual scroll for large log files (10K+ lines)
84
+ - Dark / light theme toggle
85
+ - Responsive mobile layout with cards view
81
86
  - Collapsible directory groups
87
+ - Right-click context menu on process rows
82
88
 
83
89
  ---
84
90
 
@@ -26,7 +26,12 @@ export async function GET(req: Request) {
26
26
  controller.enqueue(encoder.encode(`data: []\n\n`));
27
27
  }
28
28
 
29
- controller.enqueue(encoder.encode(`: keepalive\n\n`));
29
+ // Periodic keepalive to prevent proxy/browser timeouts
30
+ const keepaliveInterval = setInterval(() => {
31
+ try {
32
+ controller.enqueue(encoder.encode(`: keepalive\n\n`));
33
+ } catch { /* stream closed */ }
34
+ }, 15_000);
30
35
 
31
36
  // Then send updates every INTERVAL_MS
32
37
  const interval = setInterval(async () => {
@@ -40,7 +45,10 @@ export async function GET(req: Request) {
40
45
  }, INTERVAL_MS);
41
46
 
42
47
  // Store cleanup for when the stream is cancelled
43
- (stream as any).__cleanup = () => clearInterval(interval);
48
+ (stream as any).__cleanup = () => {
49
+ clearInterval(interval);
50
+ clearInterval(keepaliveInterval);
51
+ };
44
52
  },
45
53
  cancel() {
46
54
  if ((stream as any).__cleanup) {
@@ -675,6 +675,22 @@ body::after {
675
675
  opacity: 0;
676
676
  }
677
677
 
678
+ .search-count {
679
+ position: absolute;
680
+ right: 2rem;
681
+ top: 50%;
682
+ transform: translateY(-50%);
683
+ padding: 1px 6px;
684
+ font-size: 0.6rem;
685
+ font-family: var(--font-mono);
686
+ color: var(--accent);
687
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
688
+ border-radius: 8px;
689
+ background: color-mix(in srgb, var(--accent) 8%, transparent);
690
+ pointer-events: none;
691
+ letter-spacing: 0.3px;
692
+ }
693
+
678
694
  /* ─── Buttons ─── */
679
695
  .btn {
680
696
  display: inline-flex;
@@ -2040,6 +2056,14 @@ a.port-link:hover {
2040
2056
  white-space: nowrap;
2041
2057
  }
2042
2058
 
2059
+ /* ─── Virtual Scroll Spacers ─── */
2060
+ .log-virtual-top,
2061
+ .log-virtual-bottom {
2062
+ display: block;
2063
+ width: 100%;
2064
+ pointer-events: none;
2065
+ }
2066
+
2043
2067
  /* ─── Log Lines ─── */
2044
2068
  .log-line {
2045
2069
  padding: 0.1rem 0.5rem 0.1rem 0;
@@ -2446,4 +2470,141 @@ a.port-link:hover {
2446
2470
  .guard-sentinel-pill.stopped .guard-sentinel-dot {
2447
2471
  background: var(--danger);
2448
2472
  box-shadow: 0 0 4px rgba(239, 68, 68, 0.3);
2473
+ }
2474
+
2475
+ /* ─── Keyboard Navigation Focus ─── */
2476
+ tr.keyboard-focus {
2477
+ outline: 2px solid var(--accent-primary);
2478
+ outline-offset: -2px;
2479
+ background: rgba(168, 85, 247, 0.08) !important;
2480
+ border-radius: var(--radius-sm);
2481
+ }
2482
+
2483
+ tr.keyboard-focus td:first-child .process-name span {
2484
+ color: var(--accent-primary);
2485
+ }
2486
+
2487
+ /* ─── Keyboard Shortcuts Overlay ─── */
2488
+ .shortcuts-overlay {
2489
+ position: fixed;
2490
+ inset: 0;
2491
+ z-index: 2000;
2492
+ background: rgba(0, 0, 0, 0.6);
2493
+ backdrop-filter: blur(4px);
2494
+ display: flex;
2495
+ align-items: center;
2496
+ justify-content: center;
2497
+ opacity: 0;
2498
+ pointer-events: none;
2499
+ transition: opacity 0.2s ease;
2500
+ }
2501
+
2502
+ .shortcuts-overlay.active {
2503
+ opacity: 1;
2504
+ pointer-events: auto;
2505
+ }
2506
+
2507
+ .shortcuts-panel {
2508
+ background: var(--bg-secondary);
2509
+ border: 1px solid var(--border-glass);
2510
+ border-radius: var(--radius-lg);
2511
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5), 0 0 60px rgba(0, 0, 0, 0.3);
2512
+ padding: 1.5rem;
2513
+ min-width: 440px;
2514
+ max-width: 520px;
2515
+ animation: scaleIn 0.2s ease;
2516
+ }
2517
+
2518
+ .shortcuts-overlay.active .shortcuts-panel {
2519
+ animation: scaleIn 0.2s ease;
2520
+ }
2521
+
2522
+ @keyframes scaleIn {
2523
+ from {
2524
+ transform: scale(0.95);
2525
+ opacity: 0;
2526
+ }
2527
+
2528
+ to {
2529
+ transform: scale(1);
2530
+ opacity: 1;
2531
+ }
2532
+ }
2533
+
2534
+ .shortcuts-header {
2535
+ display: flex;
2536
+ align-items: center;
2537
+ justify-content: space-between;
2538
+ margin-bottom: 1.25rem;
2539
+ padding-bottom: 0.75rem;
2540
+ border-bottom: 1px solid var(--border-glass);
2541
+ }
2542
+
2543
+ .shortcuts-header h3 {
2544
+ font-size: 1rem;
2545
+ font-weight: 600;
2546
+ color: var(--text-primary);
2547
+ margin: 0;
2548
+ }
2549
+
2550
+ .shortcuts-close {
2551
+ background: none;
2552
+ border: none;
2553
+ color: var(--text-muted);
2554
+ font-size: 1.1rem;
2555
+ cursor: pointer;
2556
+ padding: 0.25rem;
2557
+ line-height: 1;
2558
+ transition: color 0.15s;
2559
+ }
2560
+
2561
+ .shortcuts-close:hover {
2562
+ color: var(--text-primary);
2563
+ }
2564
+
2565
+ .shortcuts-grid {
2566
+ display: grid;
2567
+ grid-template-columns: 1fr 1fr;
2568
+ gap: 1.5rem;
2569
+ }
2570
+
2571
+ .shortcut-section h4 {
2572
+ font-size: 0.7rem;
2573
+ text-transform: uppercase;
2574
+ letter-spacing: 0.08em;
2575
+ color: var(--text-dim);
2576
+ font-weight: 600;
2577
+ margin: 0 0 0.6rem 0;
2578
+ }
2579
+
2580
+ .shortcut-row {
2581
+ display: flex;
2582
+ align-items: center;
2583
+ gap: 0.4rem;
2584
+ padding: 0.3rem 0;
2585
+ font-size: 0.8rem;
2586
+ color: var(--text-secondary);
2587
+ }
2588
+
2589
+ .shortcut-row span {
2590
+ flex: 1;
2591
+ }
2592
+
2593
+ .shortcut-row kbd {
2594
+ display: inline-flex;
2595
+ align-items: center;
2596
+ justify-content: center;
2597
+ min-width: 24px;
2598
+ height: 24px;
2599
+ padding: 0 6px;
2600
+ background: rgba(255, 255, 255, 0.06);
2601
+ border: 1px solid rgba(255, 255, 255, 0.12);
2602
+ border-radius: 5px;
2603
+ font-family: var(--font-mono);
2604
+ font-size: 0.68rem;
2605
+ font-weight: 600;
2606
+ color: var(--text-primary);
2607
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
2608
+ line-height: 1;
2609
+ flex-shrink: 0;
2449
2610
  }
@@ -1,15 +1,10 @@
1
- /** @jsxImportSource melina/client */
2
1
  /**
3
2
  * bgrun Dashboard — Page Client Interactivity
4
3
  *
5
4
  * NOT a React component. A mount function that adds interactivity
6
- * to the server-rendered HTML. JSX here creates real DOM elements
7
- * (via Melina's jsx-dom runtime, not React virtual DOM).
8
- *
9
- * Log viewer uses Melina's VDOM render() with keyed reconciler
10
- * for efficient incremental DOM updates.
5
+ * to the server-rendered HTML. JSX creates real DOM elements via
6
+ * Melina's jsx-dom runtime (mapped from react/jsx-runtime).
11
7
  */
12
- import { render as melinaRender, createElement as h, setReconciler } from 'melina/client/render';
13
8
 
14
9
  interface ProcessData {
15
10
  name: string;
@@ -418,6 +413,7 @@ export default function mount(): () => void {
418
413
  let isFirstLoad = true;
419
414
  let allProcesses: ProcessData[] = [];
420
415
  let searchQuery = '';
416
+ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
421
417
  let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
422
418
  let drawerProcess: string | null = null;
423
419
  let drawerTab: 'stdout' | 'stderr' = 'stdout';
@@ -433,6 +429,28 @@ export default function mount(): () => void {
433
429
  let logLastSize = -1; // Detect no-change polls
434
430
  let logNeedsFullRebuild = true; // Full DOM rebuild flag (on tab switch, search change)
435
431
 
432
+ // ─── Virtual Scrolling State ───
433
+ let LOG_LINE_HEIGHT = 22; // default estimate, auto-calibrated on first render
434
+ let logLineHeightCalibrated = false;
435
+ const LOG_OVERSCAN = 10; // extra lines rendered above/below viewport
436
+ const VIRTUAL_THRESHOLD = 200; // switch to virtual mode above this many lines
437
+ let logVirtualActive = false; // whether virtual scrolling is engaged
438
+ let logFilteredIndices: number[] = []; // indices into logLinesRaw that pass the search filter
439
+ let logScrollRAF: number | null = null; // rAF handle for throttled scroll
440
+
441
+ /** Measure actual log line height from DOM on first render */
442
+ function calibrateLogLineHeight(logsEl: HTMLElement) {
443
+ if (logLineHeightCalibrated) return;
444
+ const firstLine = logsEl.querySelector('.log-line') as HTMLElement;
445
+ if (firstLine) {
446
+ const measured = firstLine.getBoundingClientRect().height;
447
+ if (measured > 0) {
448
+ LOG_LINE_HEIGHT = Math.round(measured);
449
+ logLineHeightCalibrated = true;
450
+ }
451
+ }
452
+ }
453
+
436
454
  // ─── Version Badge ───
437
455
  const versionBadge = $('version-badge');
438
456
  async function loadVersion() {
@@ -457,14 +475,18 @@ export default function mount(): () => void {
457
475
  allProcesses = await res.json();
458
476
  renderFilteredProcesses();
459
477
  updateStats(allProcesses);
460
- } catch {
461
- // silently retry on next tick
478
+ } catch (err) {
479
+ console.error('[bgr-dashboard] loadProcesses error:', err);
462
480
  } finally {
463
481
  isFetching = false;
464
482
  }
465
483
  }
466
484
 
467
485
  function renderFilteredProcesses() {
486
+ // Always sync searchQuery from DOM to prevent desync
487
+ if (searchInput && searchInput.value.toLowerCase().trim() !== searchQuery) {
488
+ searchQuery = searchInput.value.toLowerCase().trim();
489
+ }
468
490
  const filtered = searchQuery
469
491
  ? allProcesses.filter(p =>
470
492
  p.name.toLowerCase().includes(searchQuery) ||
@@ -473,6 +495,17 @@ export default function mount(): () => void {
473
495
  )
474
496
  : allProcesses;
475
497
  renderProcesses(filtered);
498
+
499
+ // Update search result count badge
500
+ const badge = $('search-count');
501
+ if (badge) {
502
+ if (searchQuery) {
503
+ badge.textContent = `${filtered.length}/${allProcesses.length}`;
504
+ badge.style.display = 'inline-block';
505
+ } else {
506
+ badge.style.display = 'none';
507
+ }
508
+ }
476
509
  }
477
510
 
478
511
  function updateStats(processes: ProcessData[]) {
@@ -526,6 +559,16 @@ export default function mount(): () => void {
526
559
  }
527
560
  }
528
561
 
562
+ function toggleGroup(groupDir: string) {
563
+ if (collapsedGroups.has(groupDir)) {
564
+ collapsedGroups.delete(groupDir);
565
+ } else {
566
+ collapsedGroups.add(groupDir);
567
+ }
568
+ localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
569
+ renderFilteredProcesses();
570
+ }
571
+
529
572
  function renderProcesses(processes: ProcessData[]) {
530
573
  const tbody = $('processes-table');
531
574
  const cardsEl = $('mobile-cards');
@@ -553,62 +596,65 @@ export default function mount(): () => void {
553
596
  groups[key].push(p);
554
597
  });
555
598
 
556
- const nodes: Node[] = [];
557
599
  const sortedGroupKeys = Object.keys(groups).sort();
558
600
 
559
- // Always show group headers for every directory
601
+ // Build DOM nodes for table rows
602
+ const rows: Node[] = [];
560
603
  sortedGroupKeys.forEach(groupDir => {
561
604
  const procs = groups[groupDir];
562
605
  const running = procs.filter(p => p.running).length;
563
606
  const collapsed = collapsedGroups.has(groupDir);
564
- nodes.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
607
+ rows.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
565
608
  if (!collapsed) {
566
609
  procs.forEach(p => {
567
- nodes.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
610
+ rows.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
568
611
  });
569
612
  }
570
613
  });
571
614
 
572
- tbody.replaceChildren(...nodes);
615
+ // Replace tbody contents with new DOM nodes
616
+ tbody.replaceChildren(...rows);
573
617
 
574
618
  // Add click handlers for group headers (toggle collapse)
575
619
  tbody.querySelectorAll('.group-header').forEach(header => {
576
620
  header.addEventListener('click', (e: Event) => {
577
- // Don't collapse if clicking action buttons
578
621
  if ((e.target as Element).closest('[data-action]')) return;
579
622
  const groupName = (header as HTMLElement).dataset.groupName;
580
623
  if (!groupName) return;
581
- if (collapsedGroups.has(groupName)) {
582
- collapsedGroups.delete(groupName);
583
- } else {
584
- collapsedGroups.add(groupName);
585
- }
586
- localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
587
- renderFilteredProcesses();
624
+ toggleGroup(groupName);
588
625
  });
589
626
  });
590
627
 
591
628
  // Render mobile cards
592
629
  if (cardsEl) {
593
- const cards = processes.map(p => <ProcessCard p={p} /> as unknown as Node);
594
- cardsEl.replaceChildren(...cards);
630
+ cardsEl.replaceChildren(...processes.map(p => <ProcessCard p={p} /> as unknown as Node));
595
631
  }
596
632
 
597
633
  if (isFirstLoad) isFirstLoad = false;
598
634
 
599
- // Highlight selected row
635
+ // Restore selected row + keyboard focus row
600
636
  if (drawerProcess) {
601
- const row = tbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
637
+ const finalTbody = $('processes-table') || tbody;
638
+ const row = finalTbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
602
639
  if (row) row.classList.add('selected');
603
640
  }
641
+ // Restore keyboard focus ring if user had a row focused
642
+ if (focusedProcessName) {
643
+ const finalTbody = $('processes-table') || tbody;
644
+ const focusRow = finalTbody.querySelector(`tr[data-process-name="${focusedProcessName}"]`);
645
+ if (focusRow) focusRow.classList.add('focus-ring');
646
+ }
604
647
  }
605
648
 
606
- // ─── Search ───
649
+ // ─── Search (debounced 150ms) ───
607
650
 
608
651
  const searchInput = $('search-input') as HTMLInputElement;
609
652
  searchInput?.addEventListener('input', () => {
610
- searchQuery = searchInput.value.toLowerCase().trim();
611
- renderFilteredProcesses();
653
+ if (searchDebounce) clearTimeout(searchDebounce);
654
+ searchDebounce = setTimeout(() => {
655
+ searchQuery = searchInput.value.toLowerCase().trim();
656
+ renderFilteredProcesses();
657
+ }, 150);
612
658
  });
613
659
 
614
660
  /** Fetch with cache-bust to force fresh data after mutations */
@@ -925,6 +971,8 @@ export default function mount(): () => void {
925
971
  logCurrentTab = '';
926
972
  logLastSize = -1;
927
973
  logNeedsFullRebuild = true;
974
+ logVirtualActive = false;
975
+ logFilteredIndices = [];
928
976
  if (!skipRefresh) refreshDrawerLogs();
929
977
  }
930
978
 
@@ -1124,8 +1172,70 @@ export default function mount(): () => void {
1124
1172
  tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
1125
1173
  }
1126
1174
 
1127
- // Use keyed reconciler for efficient log line diffing
1128
- setReconciler('keyed');
1175
+
1176
+ // ─── Build filtered indices ───
1177
+ function rebuildFilteredIndices() {
1178
+ const search = logSearch.toLowerCase();
1179
+ logFilteredIndices = [];
1180
+ for (let i = 0; i < logLinesRaw.length; i++) {
1181
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1182
+ logFilteredIndices.push(i);
1183
+ }
1184
+ }
1185
+
1186
+ // ─── Render a single log line HTML string ───
1187
+ function renderLogLineHtml(rawIndex: number): string {
1188
+ const num = rawIndex + 1;
1189
+ return `<div class="log-line" data-ln="${num}"><span class="log-line-num">${num}</span><span class="log-line-content">${logLinesHtml[rawIndex]}</span></div>`;
1190
+ }
1191
+
1192
+ // ─── Virtual scroll: render only visible slice ───
1193
+ function virtualRenderSlice(logsEl: HTMLElement) {
1194
+ const count = logFilteredIndices.length;
1195
+ if (count === 0) {
1196
+ logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
1197
+ return;
1198
+ }
1199
+
1200
+ const totalHeight = count * LOG_LINE_HEIGHT;
1201
+ const scrollTop = logsEl.scrollTop;
1202
+ const viewportH = logsEl.clientHeight;
1203
+
1204
+ // Calculate visible range with overscan
1205
+ let startIdx = Math.floor(scrollTop / LOG_LINE_HEIGHT) - LOG_OVERSCAN;
1206
+ let endIdx = Math.ceil((scrollTop + viewportH) / LOG_LINE_HEIGHT) + LOG_OVERSCAN;
1207
+ startIdx = Math.max(0, startIdx);
1208
+ endIdx = Math.min(count - 1, endIdx);
1209
+
1210
+ // Only rebuild if the visible range actually changed
1211
+ const topSpacer = logsEl.querySelector('.log-virtual-top') as HTMLElement;
1212
+ if (topSpacer && topSpacer.dataset.start === String(startIdx) && topSpacer.dataset.end === String(endIdx)) {
1213
+ return; // same range, skip DOM work
1214
+ }
1215
+
1216
+ const topH = startIdx * LOG_LINE_HEIGHT;
1217
+ const bottomH = Math.max(0, (count - endIdx - 1) * LOG_LINE_HEIGHT);
1218
+
1219
+ // Build visible lines
1220
+ const chunks: string[] = [];
1221
+ chunks.push(`<div class="log-virtual-top" data-start="${startIdx}" data-end="${endIdx}" style="height:${topH}px"></div>`);
1222
+ for (let i = startIdx; i <= endIdx; i++) {
1223
+ chunks.push(renderLogLineHtml(logFilteredIndices[i]));
1224
+ }
1225
+ chunks.push(`<div class="log-virtual-bottom" style="height:${bottomH}px"></div>`);
1226
+ logsEl.innerHTML = chunks.join('');
1227
+ }
1228
+
1229
+ // ─── Scroll handler for virtual mode ───
1230
+ function onLogScroll() {
1231
+ if (!logVirtualActive) return;
1232
+ if (logScrollRAF) return; // already scheduled
1233
+ logScrollRAF = requestAnimationFrame(() => {
1234
+ logScrollRAF = null;
1235
+ const logsEl = $('drawer-logs') as HTMLElement;
1236
+ if (logsEl) virtualRenderSlice(logsEl);
1237
+ });
1238
+ }
1129
1239
 
1130
1240
  function fullRebuildLogs(logsEl: HTMLElement) {
1131
1241
  const search = logSearch.toLowerCase();
@@ -1133,48 +1243,78 @@ export default function mount(): () => void {
1133
1243
  logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
1134
1244
  updateLogCount(0);
1135
1245
  logNeedsFullRebuild = false;
1246
+ logVirtualActive = false;
1136
1247
  return;
1137
1248
  }
1138
1249
 
1139
- // Build all HTML in one pass using cached ansiToHtml results
1140
- const chunks: string[] = [];
1141
- let count = 0;
1142
- for (let i = 0; i < logLinesRaw.length; i++) {
1143
- if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1144
- count++;
1145
- const num = i + 1;
1146
- chunks.push(`<div class="log-line" data-ln="${num}"><span class="log-line-num">${num}</span><span class="log-line-content">${logLinesHtml[i]}</span></div>`);
1147
- }
1148
- logsEl.innerHTML = chunks.join('');
1250
+ // Rebuild filtered indices
1251
+ rebuildFilteredIndices();
1252
+ const count = logFilteredIndices.length;
1149
1253
  updateLogCount(count);
1254
+
1255
+ // Decide: virtual or direct
1256
+ if (count >= VIRTUAL_THRESHOLD) {
1257
+ logVirtualActive = true;
1258
+ virtualRenderSlice(logsEl);
1259
+ } else {
1260
+ logVirtualActive = false;
1261
+ // Direct render — small enough for full DOM
1262
+ const chunks: string[] = [];
1263
+ for (const idx of logFilteredIndices) {
1264
+ chunks.push(renderLogLineHtml(idx));
1265
+ }
1266
+ logsEl.innerHTML = chunks.join('');
1267
+ }
1150
1268
  logNeedsFullRebuild = false;
1269
+
1270
+ // Auto-calibrate line height from first rendered line
1271
+ if (!logLineHeightCalibrated) {
1272
+ requestAnimationFrame(() => calibrateLogLineHeight(logsEl));
1273
+ }
1151
1274
  }
1152
1275
 
1153
1276
  function appendNewLogLines(logsEl: HTMLElement, startIndex: number) {
1154
- // Fast path: append only new lines to existing DOM
1155
1277
  const search = logSearch.toLowerCase();
1156
- const fragment = document.createDocumentFragment();
1157
- let count = 0;
1278
+
1279
+ // Append to filtered indices
1158
1280
  for (let i = startIndex; i < logLinesRaw.length; i++) {
1159
1281
  if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1160
- count++;
1161
- const div = document.createElement('div');
1162
- div.className = 'log-line';
1163
- div.setAttribute('data-ln', String(i + 1));
1164
- div.innerHTML = `<span class="log-line-num">${i + 1}</span><span class="log-line-content">${logLinesHtml[i]}</span>`;
1165
- fragment.appendChild(div);
1166
- }
1167
- if (count > 0) logsEl.appendChild(fragment);
1168
- // Update total count
1169
- const total = search
1170
- ? logLinesRaw.filter(l => l.toLowerCase().includes(search)).length
1171
- : logLinesRaw.length;
1172
- updateLogCount(total);
1282
+ logFilteredIndices.push(i);
1283
+ }
1284
+ const count = logFilteredIndices.length;
1285
+ updateLogCount(count);
1286
+
1287
+ // Check if we need to switch to virtual mode
1288
+ if (count >= VIRTUAL_THRESHOLD && !logVirtualActive) {
1289
+ logVirtualActive = true;
1290
+ virtualRenderSlice(logsEl);
1291
+ return;
1292
+ }
1293
+
1294
+ if (logVirtualActive) {
1295
+ // In virtual mode, re-render the current visible slice
1296
+ virtualRenderSlice(logsEl);
1297
+ } else {
1298
+ // Direct DOM append for small logs
1299
+ const fragment = document.createDocumentFragment();
1300
+ for (let i = startIndex; i < logLinesRaw.length; i++) {
1301
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1302
+ const div = document.createElement('div');
1303
+ div.className = 'log-line';
1304
+ div.setAttribute('data-ln', String(i + 1));
1305
+ div.innerHTML = `<span class="log-line-num">${i + 1}</span><span class="log-line-content">${logLinesHtml[i]}</span>`;
1306
+ fragment.appendChild(div);
1307
+ }
1308
+ if (fragment.childNodes.length > 0) logsEl.appendChild(fragment);
1309
+ }
1173
1310
  }
1174
1311
 
1175
1312
  function updateLogCount(count: number) {
1176
1313
  const countEl = $('log-line-count');
1177
- if (countEl) countEl.textContent = `${count} line${count !== 1 ? 's' : ''}`;
1314
+ if (countEl) {
1315
+ const suffix = logVirtualActive ? ' (virtual)' : '';
1316
+ countEl.textContent = `${count} line${count !== 1 ? 's' : ''}${suffix}`;
1317
+ }
1178
1318
  }
1179
1319
 
1180
1320
  async function refreshDrawerLogs() {
@@ -1287,6 +1427,10 @@ export default function mount(): () => void {
1287
1427
 
1288
1428
  // Click log line → expand/collapse (word-wrap toggle)
1289
1429
  const logsContainer = $('drawer-logs');
1430
+
1431
+ // Virtual scroll handler — drives re-render on scroll in virtual mode
1432
+ logsContainer?.addEventListener('scroll', onLogScroll, { passive: true });
1433
+
1290
1434
  logsContainer?.addEventListener('click', (e: Event) => {
1291
1435
  const line = (e.target as Element).closest('.log-line') as HTMLElement;
1292
1436
  if (!line) return;
@@ -1470,6 +1614,9 @@ export default function mount(): () => void {
1470
1614
  if (drawerProcess) refreshDrawerLogs();
1471
1615
  });
1472
1616
 
1617
+ // ─── Shortcuts Button ───
1618
+ $('shortcuts-btn')?.addEventListener('click', toggleShortcutsOverlay);
1619
+
1473
1620
  // ─── Guard All Button ───
1474
1621
  $('guard-all-btn')?.addEventListener('click', async () => {
1475
1622
  const guardAllBtn = $('guard-all-btn') as HTMLButtonElement;
@@ -1513,14 +1660,95 @@ export default function mount(): () => void {
1513
1660
  // Group toggle removed — always-on directory grouping
1514
1661
 
1515
1662
  // ─── Keyboard Shortcuts ───
1663
+ let focusedProcessName: string | null = null;
1664
+
1665
+ function getFocusableRows(): HTMLElement[] {
1666
+ const rows = tbody?.querySelectorAll('tr[data-process-name]') as NodeListOf<HTMLElement> | undefined;
1667
+ return rows ? Array.from(rows) : [];
1668
+ }
1669
+
1670
+ function setProcessFocus(name: string | null) {
1671
+ // Remove previous focus
1672
+ tbody?.querySelectorAll('tr.keyboard-focus').forEach(r => r.classList.remove('keyboard-focus'));
1673
+ focusedProcessName = name;
1674
+ if (!name) return;
1675
+ const row = tbody?.querySelector(`tr[data-process-name="${name}"]`) as HTMLElement;
1676
+ if (row) {
1677
+ row.classList.add('keyboard-focus');
1678
+ row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1679
+ }
1680
+ }
1681
+
1682
+ function navigateProcess(direction: 'up' | 'down') {
1683
+ const rows = getFocusableRows();
1684
+ if (rows.length === 0) return;
1685
+
1686
+ if (!focusedProcessName) {
1687
+ // Nothing focused: pick first or last
1688
+ const target = direction === 'down' ? rows[0] : rows[rows.length - 1];
1689
+ setProcessFocus(target.dataset.processName || null);
1690
+ return;
1691
+ }
1692
+
1693
+ const idx = rows.findIndex(r => r.dataset.processName === focusedProcessName);
1694
+ if (idx === -1) {
1695
+ setProcessFocus(rows[0].dataset.processName || null);
1696
+ return;
1697
+ }
1698
+
1699
+ const nextIdx = direction === 'down'
1700
+ ? Math.min(idx + 1, rows.length - 1)
1701
+ : Math.max(idx - 1, 0);
1702
+ setProcessFocus(rows[nextIdx].dataset.processName || null);
1703
+ }
1704
+
1705
+ /** Dispatch a process action by synthesizing a click on a virtual button */
1706
+ function dispatchAction(actionName: string, processName: string) {
1707
+ const fakeBtn = document.createElement('button');
1708
+ fakeBtn.dataset.action = actionName;
1709
+ fakeBtn.dataset.name = processName;
1710
+ // For guard toggle, read current state
1711
+ if (actionName === 'guard') {
1712
+ const proc = allProcesses.find(p => p.name === processName);
1713
+ fakeBtn.dataset.guarded = proc && isGuarded(proc) ? 'true' : 'false';
1714
+ }
1715
+ const fakeEvent = new MouseEvent('click');
1716
+ Object.defineProperty(fakeEvent, 'target', { value: fakeBtn });
1717
+ handleAction(fakeEvent);
1718
+ }
1719
+
1720
+ function toggleShortcutsOverlay() {
1721
+ const overlay = $('shortcuts-overlay');
1722
+ if (overlay) overlay.classList.toggle('active');
1723
+ }
1724
+
1725
+ $('shortcuts-close-btn')?.addEventListener('click', () => {
1726
+ $('shortcuts-overlay')?.classList.remove('active');
1727
+ });
1728
+ $('shortcuts-overlay')?.addEventListener('click', (e) => {
1729
+ if ((e.target as Element).classList.contains('shortcuts-overlay')) {
1730
+ $('shortcuts-overlay')?.classList.remove('active');
1731
+ }
1732
+ });
1733
+
1516
1734
  function handleKeydown(e: KeyboardEvent) {
1735
+ // Skip all shortcuts when inside text inputs or textareas
1736
+ const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
1737
+
1517
1738
  // "/" to focus search (unless already in an input)
1518
- if (e.key === '/' && !(e.target instanceof HTMLInputElement)) {
1739
+ if (e.key === '/' && !inInput) {
1519
1740
  e.preventDefault();
1520
1741
  searchInput?.focus();
1521
1742
  return;
1522
1743
  }
1744
+
1745
+ // Escape: close overlays progressively
1523
1746
  if (e.key === 'Escape') {
1747
+ const shortcutsOverlay = $('shortcuts-overlay');
1748
+ if (shortcutsOverlay?.classList.contains('active')) {
1749
+ shortcutsOverlay.classList.remove('active');
1750
+ return;
1751
+ }
1524
1752
  if (contextMenuEl) {
1525
1753
  closeContextMenu();
1526
1754
  return;
@@ -1530,10 +1758,75 @@ export default function mount(): () => void {
1530
1758
  } else {
1531
1759
  closeModal();
1532
1760
  }
1761
+ // Clear keyboard focus
1762
+ setProcessFocus(null);
1533
1763
  // Blur search on escape
1534
1764
  if (document.activeElement === searchInput) {
1535
1765
  searchInput?.blur();
1536
1766
  }
1767
+ return;
1768
+ }
1769
+
1770
+ // Remaining shortcuts only when NOT in inputs
1771
+ if (inInput) return;
1772
+
1773
+ // Arrow navigation
1774
+ if (e.key === 'ArrowDown' || e.key === 'j') {
1775
+ e.preventDefault();
1776
+ navigateProcess('down');
1777
+ return;
1778
+ }
1779
+ if (e.key === 'ArrowUp' || e.key === 'k') {
1780
+ e.preventDefault();
1781
+ navigateProcess('up');
1782
+ return;
1783
+ }
1784
+
1785
+ // Enter: open drawer for focused process
1786
+ if (e.key === 'Enter' && focusedProcessName) {
1787
+ e.preventDefault();
1788
+ openDrawer(focusedProcessName);
1789
+ return;
1790
+ }
1791
+
1792
+ // ? — help overlay
1793
+ if (e.key === '?') {
1794
+ e.preventDefault();
1795
+ toggleShortcutsOverlay();
1796
+ return;
1797
+ }
1798
+
1799
+ // N — new process modal
1800
+ if (e.key === 'n' || e.key === 'N') {
1801
+ e.preventDefault();
1802
+ openModal();
1803
+ return;
1804
+ }
1805
+
1806
+ // Process actions — require a focused row
1807
+ if (!focusedProcessName) return;
1808
+
1809
+ if (e.key === 'r' || e.key === 'R') {
1810
+ e.preventDefault();
1811
+ dispatchAction('restart', focusedProcessName);
1812
+ return;
1813
+ }
1814
+ if (e.key === 's' || e.key === 'S') {
1815
+ e.preventDefault();
1816
+ dispatchAction('stop', focusedProcessName);
1817
+ return;
1818
+ }
1819
+ if (e.key === 'g' || e.key === 'G') {
1820
+ e.preventDefault();
1821
+ dispatchAction('guard', focusedProcessName);
1822
+ return;
1823
+ }
1824
+ if (e.key === 'd' || e.key === 'D') {
1825
+ e.preventDefault();
1826
+ dispatchAction('delete', focusedProcessName);
1827
+ // Clear focus since process is gone
1828
+ setProcessFocus(null);
1829
+ return;
1537
1830
  }
1538
1831
  }
1539
1832
  document.addEventListener('keydown', handleKeydown);
@@ -1542,10 +1835,17 @@ export default function mount(): () => void {
1542
1835
  let eventSource: EventSource | null = null;
1543
1836
  let logRefreshTimer: ReturnType<typeof setInterval> | null = null;
1544
1837
  let sseThrottleTimer: ReturnType<typeof setTimeout> | null = null;
1838
+ let sseRetryDelay = 2_000; // exponential backoff start
1839
+ const SSE_MAX_RETRY = 30_000; // max 30s between retries
1840
+
1841
+ // Initial data load — don't depend on SSE for first render
1842
+ loadProcesses();
1545
1843
 
1546
1844
  function connectSSE() {
1845
+ if (eventSource) { eventSource.close(); eventSource = null; }
1547
1846
  eventSource = new EventSource('/api/events');
1548
1847
  eventSource.onmessage = (event) => {
1848
+ sseRetryDelay = 2_000; // reset backoff on success
1549
1849
  // Skip SSE updates briefly after mutations to avoid flicker
1550
1850
  if (Date.now() < mutationUntil) return;
1551
1851
  try {
@@ -1561,14 +1861,29 @@ export default function mount(): () => void {
1561
1861
  } catch { /* invalid data, skip */ }
1562
1862
  };
1563
1863
  eventSource.onerror = () => {
1564
- // SSE disconnected, reconnect after 5s
1864
+ // SSE disconnected exponential backoff reconnect
1565
1865
  eventSource?.close();
1566
1866
  eventSource = null;
1567
- setTimeout(connectSSE, 5000);
1867
+ setTimeout(connectSSE, sseRetryDelay);
1868
+ sseRetryDelay = Math.min(sseRetryDelay * 2, SSE_MAX_RETRY);
1568
1869
  };
1569
1870
  }
1570
1871
  connectSSE();
1571
1872
 
1873
+ // Pause SSE when tab is hidden, resume when visible
1874
+ function handleVisibility() {
1875
+ if (document.hidden) {
1876
+ eventSource?.close();
1877
+ eventSource = null;
1878
+ } else {
1879
+ if (!eventSource) {
1880
+ sseRetryDelay = 2_000; // reset on manual re-focus
1881
+ connectSSE();
1882
+ }
1883
+ }
1884
+ }
1885
+ document.addEventListener('visibilitychange', handleVisibility);
1886
+
1572
1887
  // Log drawer still needs periodic refresh (not part of SSE)
1573
1888
  logRefreshTimer = setInterval(() => {
1574
1889
  if (drawerProcess) refreshDrawerLogs();
@@ -1584,9 +1899,13 @@ export default function mount(): () => void {
1584
1899
  $('modal-create-btn')?.removeEventListener('click', createProcess);
1585
1900
  $('refresh-btn')?.removeEventListener('click', loadProcesses);
1586
1901
  document.removeEventListener('keydown', handleKeydown);
1902
+ document.removeEventListener('visibilitychange', handleVisibility);
1587
1903
  closeContextMenu();
1588
1904
  if (eventSource) eventSource.close();
1589
1905
  if (logRefreshTimer) clearInterval(logRefreshTimer);
1590
1906
  if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
1907
+ if (searchDebounce) clearTimeout(searchDebounce);
1908
+ if (logScrollRAF) cancelAnimationFrame(logScrollRAF);
1909
+ logsContainer?.removeEventListener('scroll', onLogScroll);
1591
1910
  };
1592
1911
  }
@@ -53,6 +53,7 @@ export default function DashboardPage() {
53
53
  <line x1="21" y1="21" x2="16.65" y2="16.65" />
54
54
  </svg>
55
55
  <input type="text" className="search-input" id="search-input" placeholder="Filter processes..." />
56
+ <span className="search-count" id="search-count" style={{ display: 'none' }}></span>
56
57
  <span className="search-shortcut">/</span>
57
58
  </div>
58
59
  </div>
@@ -73,6 +74,9 @@ export default function DashboardPage() {
73
74
  <span className="guard-sentinel-dot" id="guard-sentinel-dot" />
74
75
  <span id="guard-sentinel-label">Guard: –</span>
75
76
  </span>
77
+ <button className="btn btn-ghost btn-icon" id="shortcuts-btn" title="Keyboard Shortcuts (?)">
78
+ <span style={{ fontSize: '0.85rem', fontWeight: '700' }}>?</span>
79
+ </button>
76
80
  <button className="btn btn-primary" id="new-process-btn">
77
81
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
78
82
  <line x1="12" y1="5" x2="12" y2="19" />
@@ -221,6 +225,34 @@ export default function DashboardPage() {
221
225
  </div>
222
226
  </div>
223
227
  </div>
228
+
229
+ {/* Keyboard Shortcuts Overlay */}
230
+ <div className="shortcuts-overlay" id="shortcuts-overlay">
231
+ <div className="shortcuts-panel">
232
+ <div className="shortcuts-header">
233
+ <h3>⌨️ Keyboard Shortcuts</h3>
234
+ <button className="shortcuts-close" id="shortcuts-close-btn">✕</button>
235
+ </div>
236
+ <div className="shortcuts-grid">
237
+ <div className="shortcut-section">
238
+ <h4>Navigation</h4>
239
+ <div className="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Navigate processes</span></div>
240
+ <div className="shortcut-row"><kbd>Enter</kbd><span>Open process drawer</span></div>
241
+ <div className="shortcut-row"><kbd>/</kbd><span>Focus search</span></div>
242
+ <div className="shortcut-row"><kbd>Esc</kbd><span>Close panel / blur</span></div>
243
+ </div>
244
+ <div className="shortcut-section">
245
+ <h4>Actions</h4>
246
+ <div className="shortcut-row"><kbd>R</kbd><span>Restart process</span></div>
247
+ <div className="shortcut-row"><kbd>S</kbd><span>Stop process</span></div>
248
+ <div className="shortcut-row"><kbd>G</kbd><span>Toggle guard</span></div>
249
+ <div className="shortcut-row"><kbd>D</kbd><span>Delete process</span></div>
250
+ <div className="shortcut-row"><kbd>N</kbd><span>New process</span></div>
251
+ <div className="shortcut-row"><kbd>?</kbd><span>This help</span></div>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </div>
224
256
  </div>
225
257
  );
226
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "bgrun — A lightweight process manager for Bun",
5
5
  "type": "module",
6
6
  "main": "./src/api.ts",
@@ -19,6 +19,7 @@
19
19
  "dist",
20
20
  "src",
21
21
  "dashboard/app",
22
+ "scripts",
22
23
  "README.md",
23
24
  "image.png",
24
25
  "examples/bgr-startup.sh"
@@ -50,7 +51,7 @@
50
51
  "chalk": "^5.4.1",
51
52
  "dedent": "^1.5.3",
52
53
  "measure-fn": "3.10.1",
53
- "melina": "2.3.3",
54
+ "melina": "2.3.6",
54
55
  "react": "^19.2.4",
55
56
  "react-dom": "^19.2.4",
56
57
  "sqlite-zod-orm": "3.26.1"
@@ -0,0 +1,118 @@
1
+ # bgr-startup.ps1 — Auto-start bgrun guard on Windows login
2
+ # Ensures all guarded processes (and the dashboard itself) start on boot.
3
+ #
4
+ # Installation:
5
+ # 1. Run this script once with -Install flag:
6
+ # powershell -ExecutionPolicy Bypass -File bgr-startup.ps1 -Install
7
+ #
8
+ # 2. Or manually create a Task Scheduler task:
9
+ # - Trigger: At log on
10
+ # - Action: powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\Code\bgr\scripts\bgr-startup.ps1"
11
+ # - Run whether user is logged on or not: Yes
12
+ #
13
+ # Usage:
14
+ # bgr-startup.ps1 # Start bgrun guard
15
+ # bgr-startup.ps1 -Install # Register Task Scheduler entry
16
+
17
+ param(
18
+ [switch]$Install
19
+ )
20
+
21
+ $BunPath = "$env:USERPROFILE\.bun\bin\bun.exe"
22
+ $BgrunPath = "C:\Code\bgr"
23
+ $LogPath = "$env:USERPROFILE\.bgr\startup.log"
24
+
25
+ function Write-Log {
26
+ param([string]$Message)
27
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
28
+ $line = "[$timestamp] $Message"
29
+ Write-Host $line
30
+ Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
31
+ }
32
+
33
+ # Ensure .bgr directory exists
34
+ $bgrDir = "$env:USERPROFILE\.bgr"
35
+ if (-not (Test-Path $bgrDir)) {
36
+ New-Item -ItemType Directory -Path $bgrDir -Force | Out-Null
37
+ }
38
+
39
+ if ($Install) {
40
+ Write-Log "Installing bgrun auto-start task..."
41
+
42
+ $scriptPath = $PSCommandPath
43
+ if (-not $scriptPath) {
44
+ $scriptPath = Join-Path $BgrunPath "scripts\bgr-startup.ps1"
45
+ }
46
+
47
+ # Remove existing task if present
48
+ $existingTask = Get-ScheduledTask -TaskName "bgrun-guard" -ErrorAction SilentlyContinue
49
+ if ($existingTask) {
50
+ Unregister-ScheduledTask -TaskName "bgrun-guard" -Confirm:$false
51
+ Write-Log "Removed existing bgrun-guard task"
52
+ }
53
+
54
+ # Create the scheduled task
55
+ $action = New-ScheduledTaskAction `
56
+ -Execute "powershell.exe" `
57
+ -Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`""
58
+
59
+ $trigger = New-ScheduledTaskTrigger -AtLogon
60
+ $settings = New-ScheduledTaskSettingsSet `
61
+ -AllowStartIfOnBatteries `
62
+ -DontStopIfGoingOnBatteries `
63
+ -StartWhenAvailable `
64
+ -RestartInterval (New-TimeSpan -Minutes 5) `
65
+ -RestartCount 3
66
+
67
+ Register-ScheduledTask `
68
+ -TaskName "bgrun-guard" `
69
+ -Description "bgrun process manager — auto-starts all guarded processes on login" `
70
+ -Action $action `
71
+ -Trigger $trigger `
72
+ -Settings $settings `
73
+ -RunLevel Highest `
74
+ -Force
75
+
76
+ Write-Log "✓ Task 'bgrun-guard' registered. Will start on next login."
77
+ Write-Log " Script: $scriptPath"
78
+ exit 0
79
+ }
80
+
81
+ # ── Main: Start bgrun guard ─────────────────────────────────
82
+ Write-Log "bgrun startup initiated"
83
+
84
+ # Check bun exists
85
+ if (-not (Test-Path $BunPath)) {
86
+ Write-Log "ERROR: bun not found at $BunPath"
87
+ exit 1
88
+ }
89
+
90
+ # Check bgrun repo exists
91
+ if (-not (Test-Path "$BgrunPath\src\guard.ts")) {
92
+ Write-Log "ERROR: bgrun not found at $BgrunPath"
93
+ exit 1
94
+ }
95
+
96
+ # Start the dashboard first (guard needs it)
97
+ Write-Log "Starting bgrun dashboard..."
98
+ $dashboardProc = Start-Process -FilePath $BunPath `
99
+ -ArgumentList "run", "$BgrunPath\src\index.ts", "--dashboard", "--port", "3000" `
100
+ -WindowStyle Hidden `
101
+ -PassThru `
102
+ -WorkingDirectory $BgrunPath
103
+
104
+ Write-Log "Dashboard PID: $($dashboardProc.Id)"
105
+
106
+ # Wait for dashboard to be ready
107
+ Start-Sleep -Seconds 5
108
+
109
+ # Start the guard (watches dashboard + all guarded processes)
110
+ Write-Log "Starting bgrun guard..."
111
+ $guardProc = Start-Process -FilePath $BunPath `
112
+ -ArgumentList "run", "$BgrunPath\src\index.ts", "--guard" `
113
+ -WindowStyle Hidden `
114
+ -PassThru `
115
+ -WorkingDirectory $BgrunPath
116
+
117
+ Write-Log "Guard PID: $($guardProc.Id)"
118
+ Write-Log "✓ bgrun startup complete. Dashboard: $($dashboardProc.Id), Guard: $($guardProc.Id)"
@@ -0,0 +1,109 @@
1
+ /**
2
+ * bgrun core utility tests
3
+ *
4
+ * Tests pure logic functions: env parsing, config flattening,
5
+ * string truncation, and runtime calculation.
6
+ *
7
+ * Run: bun test src/bgrun.test.ts
8
+ */
9
+ import { describe, expect, test } from 'bun:test'
10
+ import { parseEnvString, calculateRuntime } from './utils'
11
+ import { stripAnsi, truncateString, truncatePath } from './table'
12
+
13
+ // ─── parseEnvString ─────────────────────────────────────
14
+
15
+ describe('parseEnvString', () => {
16
+ test('parses comma-separated key=value pairs', () => {
17
+ const result = parseEnvString('PORT=3000,HOST=localhost,DEBUG=true')
18
+ expect(result).toEqual({
19
+ PORT: '3000',
20
+ HOST: 'localhost',
21
+ DEBUG: 'true',
22
+ })
23
+ })
24
+
25
+ test('handles single pair', () => {
26
+ expect(parseEnvString('KEY=value')).toEqual({ KEY: 'value' })
27
+ })
28
+
29
+ test('handles empty string', () => {
30
+ expect(parseEnvString('')).toEqual({})
31
+ })
32
+
33
+ test('ignores malformed pairs (no =)', () => {
34
+ const result = parseEnvString('GOOD=yes,BAD,ALSO_GOOD=ok')
35
+ expect(result.GOOD).toBe('yes')
36
+ expect(result.ALSO_GOOD).toBe('ok')
37
+ expect(result.BAD).toBeUndefined()
38
+ })
39
+ })
40
+
41
+ // ─── calculateRuntime ───────────────────────────────────
42
+
43
+ describe('calculateRuntime', () => {
44
+ test('returns 0 minutes for recent start', () => {
45
+ const now = new Date().toISOString()
46
+ expect(calculateRuntime(now)).toBe('0 minutes')
47
+ })
48
+
49
+ test('returns correct minutes', () => {
50
+ const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
51
+ expect(calculateRuntime(fiveMinAgo)).toBe('5 minutes')
52
+ })
53
+
54
+ test('returns correct for 1 hour', () => {
55
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
56
+ expect(calculateRuntime(oneHourAgo)).toBe('60 minutes')
57
+ })
58
+ })
59
+
60
+ // ─── stripAnsi ──────────────────────────────────────────
61
+
62
+ describe('stripAnsi', () => {
63
+ test('strips color codes', () => {
64
+ const colored = '\u001b[31mred text\u001b[0m'
65
+ expect(stripAnsi(colored)).toBe('red text')
66
+ })
67
+
68
+ test('passes through plain text', () => {
69
+ expect(stripAnsi('hello world')).toBe('hello world')
70
+ })
71
+
72
+ test('handles empty string', () => {
73
+ expect(stripAnsi('')).toBe('')
74
+ })
75
+ })
76
+
77
+ // ─── truncateString ─────────────────────────────────────
78
+
79
+ describe('truncateString', () => {
80
+ test('returns string unchanged if within limit', () => {
81
+ expect(truncateString('hello', 10)).toBe('hello')
82
+ })
83
+
84
+ test('truncates with ellipsis', () => {
85
+ const result = truncateString('a very long string that exceeds limit', 15)
86
+ expect(result.length).toBeLessThanOrEqual(15)
87
+ expect(result).toContain('…')
88
+ })
89
+
90
+ test('handles maxLength smaller than ellipsis', () => {
91
+ const result = truncateString('hello world', 2)
92
+ expect(result.length).toBeLessThanOrEqual(2)
93
+ })
94
+ })
95
+
96
+ // ─── truncatePath ───────────────────────────────────────
97
+
98
+ describe('truncatePath', () => {
99
+ test('returns path unchanged if within limit', () => {
100
+ expect(truncatePath('/home/user', 50)).toBe('/home/user')
101
+ })
102
+
103
+ test('truncates middle of long path', () => {
104
+ const longPath = '/home/user/projects/very/deeply/nested/directory/structure'
105
+ const result = truncatePath(longPath, 30)
106
+ expect(result.length).toBeLessThanOrEqual(30)
107
+ expect(result).toContain('…')
108
+ })
109
+ })
package/src/table.ts CHANGED
@@ -34,12 +34,12 @@ export function getTerminalWidth(): number {
34
34
  }
35
35
 
36
36
  // Strip ANSI color codes for accurate length calculation
37
- function stripAnsi(str: string): string {
37
+ export function stripAnsi(str: string): string {
38
38
  return str.replace(/\u001b\[[0-9;]*m/g, "");
39
39
  }
40
40
 
41
41
  // Default truncator: trims the end of a string
42
- function truncateString(str: string, maxLength: number): string {
42
+ export function truncateString(str: string, maxLength: number): string {
43
43
  const stripped = stripAnsi(str);
44
44
  if (stripped.length <= maxLength) return str;
45
45
  const ellipsis = "…";
@@ -52,7 +52,7 @@ function truncateString(str: string, maxLength: number): string {
52
52
  }
53
53
 
54
54
  // Path truncator: trims the middle of a string
55
- function truncatePath(str: string, maxLength: number): string {
55
+ export function truncatePath(str: string, maxLength: number): string {
56
56
  const stripped = stripAnsi(str);
57
57
  if (stripped.length <= maxLength) return str;
58
58
  const ellipsis = "…";