viberadar 0.3.3 → 0.3.5

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.
@@ -46,6 +46,43 @@
46
46
  .header-project { margin-left: auto; font-size: 13px; color: var(--muted); }
47
47
  .header-time { font-size: 12px; color: var(--dim); }
48
48
 
49
+ /* ── Coverage button ─────────────────────────────────────────────────────── */
50
+ #covBtn {
51
+ padding: 5px 12px;
52
+ background: var(--bg);
53
+ border: 1px solid var(--border);
54
+ border-radius: 6px;
55
+ color: var(--muted);
56
+ font-size: 12px;
57
+ cursor: pointer;
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 5px;
61
+ transition: background 0.1s, color 0.1s, border-color 0.1s;
62
+ white-space: nowrap;
63
+ }
64
+ #covBtn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
65
+ #covBtn:disabled { cursor: not-allowed; opacity: 0.7; }
66
+ #covBtn.cov-running { color: var(--yellow); border-color: var(--yellow); }
67
+ #covBtn.cov-error { color: var(--red); border-color: var(--red); }
68
+ #covBtn.cov-done { color: var(--green); border-color: var(--green); }
69
+ #termBtn {
70
+ padding: 5px 12px;
71
+ background: var(--bg);
72
+ border: 1px solid var(--border);
73
+ border-radius: 6px;
74
+ color: var(--muted);
75
+ font-size: 12px;
76
+ cursor: pointer;
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 5px;
80
+ transition: background 0.1s, color 0.1s, border-color 0.1s;
81
+ white-space: nowrap;
82
+ }
83
+ #termBtn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
84
+ #termBtn.term-active { color: var(--accent); border-color: var(--accent); }
85
+
49
86
  /* ── Stats bar ───────────────────────────────────────────────────────────── */
50
87
  .stats-bar {
51
88
  display: flex;
@@ -339,6 +376,187 @@
339
376
  font-family: monospace;
340
377
  }
341
378
 
379
+ /* ── Feature drill-down ──────────────────────────────────────────────────── */
380
+ .drill-header {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 14px;
384
+ padding-bottom: 14px;
385
+ border-bottom: 1px solid var(--border);
386
+ margin-bottom: 16px;
387
+ flex-wrap: wrap;
388
+ }
389
+ .back-btn {
390
+ background: none;
391
+ border: 1px solid var(--border);
392
+ border-radius: 6px;
393
+ color: var(--muted);
394
+ cursor: pointer;
395
+ padding: 5px 12px;
396
+ font-size: 12px;
397
+ flex-shrink: 0;
398
+ transition: background 0.1s, color 0.1s;
399
+ }
400
+ .back-btn:hover { background: var(--border); color: var(--text); }
401
+ .drill-title {
402
+ display: flex; align-items: center; gap: 8px;
403
+ font-size: 17px; font-weight: 700;
404
+ }
405
+ .drill-stats {
406
+ display: flex; gap: 16px;
407
+ font-size: 12px; color: var(--muted);
408
+ margin-left: auto;
409
+ }
410
+ .drill-desc {
411
+ font-size: 13px; color: var(--muted);
412
+ margin-bottom: 14px; line-height: 1.5;
413
+ }
414
+ .drill-section-label {
415
+ font-size: 10px; text-transform: uppercase;
416
+ letter-spacing: 0.5px; color: var(--muted);
417
+ margin: 14px 0 6px;
418
+ }
419
+
420
+ /* ── Test-type cards inside feature detail ───────────────────────────────── */
421
+ .test-type-grid {
422
+ display: grid;
423
+ grid-template-columns: repeat(4, 1fr);
424
+ gap: 10px;
425
+ margin-bottom: 20px;
426
+ }
427
+ .test-type-card {
428
+ background: var(--bg-card);
429
+ border: 1px solid var(--border);
430
+ border-radius: 8px;
431
+ padding: 14px 16px;
432
+ cursor: pointer;
433
+ transition: background 0.15s, border-color 0.15s;
434
+ position: relative;
435
+ overflow: hidden;
436
+ }
437
+ .test-type-card:hover { background: var(--bg-hover); border-color: var(--dim); }
438
+ .test-type-card .tt-accent {
439
+ position: absolute; top: 0; left: 0; right: 0; height: 3px;
440
+ }
441
+ .test-type-card .tt-label {
442
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
443
+ color: var(--muted); margin-bottom: 6px; margin-top: 2px;
444
+ }
445
+ .test-type-card .tt-count {
446
+ font-size: 26px; font-weight: 700; line-height: 1;
447
+ margin-bottom: 4px;
448
+ }
449
+ .test-type-card .tt-sub {
450
+ font-size: 11px; color: var(--dim);
451
+ }
452
+ .test-type-card.tt-empty .tt-count { color: var(--dim); }
453
+ .test-type-card.tt-empty { opacity: 0.7; }
454
+ .test-type-card.tt-active {
455
+ background: var(--bg-hover);
456
+ border-width: 2px;
457
+ }
458
+ .file-rows { display: flex; flex-direction: column; gap: 2px; }
459
+ .file-row {
460
+ display: grid;
461
+ grid-template-columns: 22px 1fr auto;
462
+ align-items: center;
463
+ gap: 8px;
464
+ padding: 7px 10px;
465
+ border-radius: 6px;
466
+ cursor: pointer;
467
+ font-size: 13px;
468
+ transition: background 0.1s;
469
+ }
470
+ .file-row:hover { background: var(--bg-card); }
471
+ .file-row.active { background: var(--bg-hover); border-left: 2px solid var(--blue); padding-left: 8px; }
472
+ .file-row-icon { font-size: 12px; }
473
+ .file-row-name { font-weight: 500; word-break: break-all; }
474
+ .file-row-dir { font-size: 11px; color: var(--dim); text-align: right; word-break: break-word; }
475
+
476
+ /* ── Agent setup banner ──────────────────────────────────────────────────── */
477
+ .agent-setup-banner {
478
+ background: linear-gradient(135deg, #161b22 0%, #1c2230 100%);
479
+ border: 1px solid var(--blue);
480
+ border-radius: 10px;
481
+ padding: 24px;
482
+ margin-bottom: 20px;
483
+ text-align: center;
484
+ }
485
+ .agent-setup-banner h3 { font-size: 15px; margin-bottom: 6px; }
486
+ .agent-setup-banner p { font-size: 12px; color: var(--muted); margin-bottom: 16px; }
487
+ .agent-choices { display: flex; gap: 10px; justify-content: center; }
488
+ .agent-choice-btn {
489
+ padding: 10px 24px;
490
+ border-radius: 8px;
491
+ border: 1px solid var(--border);
492
+ background: var(--bg);
493
+ color: var(--text);
494
+ font-size: 13px;
495
+ font-weight: 600;
496
+ cursor: pointer;
497
+ transition: background 0.15s, border-color 0.15s, transform 0.1s;
498
+ }
499
+ .agent-choice-btn:hover { background: var(--bg-hover); border-color: var(--blue); transform: translateY(-1px); }
500
+
501
+ /* ── Agent card button ───────────────────────────────────────────────────── */
502
+ .agent-card-btn {
503
+ margin-top: 10px;
504
+ padding: 5px 10px;
505
+ background: var(--bg);
506
+ border: 1px solid var(--border);
507
+ border-radius: 5px;
508
+ color: var(--blue);
509
+ font-size: 11px;
510
+ font-weight: 600;
511
+ cursor: pointer;
512
+ transition: background 0.1s, border-color 0.1s;
513
+ width: 100%;
514
+ text-align: left;
515
+ }
516
+ .agent-card-btn:hover { background: var(--bg-hover); border-color: var(--blue); }
517
+ .agent-card-btn:disabled { opacity: 0.5; cursor: not-allowed; }
518
+
519
+ /* ── Agent terminal panel ────────────────────────────────────────────────── */
520
+ .agent-panel {
521
+ position: fixed;
522
+ bottom: 0; left: 0; right: 0;
523
+ height: 280px;
524
+ background: #090d13;
525
+ border-top: 1px solid var(--border);
526
+ transform: translateY(100%);
527
+ transition: transform 0.25s ease;
528
+ z-index: 200;
529
+ display: flex;
530
+ flex-direction: column;
531
+ }
532
+ .agent-panel.open { transform: translateY(0); }
533
+ .agent-panel-header {
534
+ display: flex;
535
+ align-items: center;
536
+ gap: 10px;
537
+ padding: 8px 16px;
538
+ background: var(--bg-card);
539
+ border-bottom: 1px solid var(--border);
540
+ flex-shrink: 0;
541
+ }
542
+ .agent-panel-title { font-size: 13px; font-weight: 600; flex: 1; }
543
+ .agent-panel-status { font-size: 11px; color: var(--muted); }
544
+ .agent-panel-close {
545
+ background: none; border: none; color: var(--muted); cursor: pointer;
546
+ font-size: 14px; padding: 3px 6px; border-radius: 4px; line-height: 1;
547
+ }
548
+ .agent-panel-close:hover { background: var(--border); color: var(--text); }
549
+ .agent-terminal {
550
+ flex: 1;
551
+ overflow-y: auto;
552
+ padding: 10px 16px;
553
+ font-family: 'Consolas', 'Menlo', 'Courier New', monospace;
554
+ font-size: 12px;
555
+ line-height: 1.5;
556
+ }
557
+ .agent-line { color: #c9d1d9; }
558
+ .agent-line.err { color: var(--red); }
559
+
342
560
  /* ── Misc ────────────────────────────────────────────────────────────────── */
343
561
  .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
344
562
  .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
@@ -351,6 +569,8 @@
351
569
  <h1>VibeRadar</h1>
352
570
  <span class="header-project" id="projectName">—</span>
353
571
  <span class="header-time" id="scannedAt"></span>
572
+ <button id="covBtn" onclick="runCoverage()" title="Запустить тесты с coverage">🧪 Coverage</button>
573
+ <button id="termBtn" onclick="toggleAgentPanel()" title="Показать/скрыть терминал агента">📟 Terminal</button>
354
574
  <span id="liveDot" title="Connecting…" style="
355
575
  width:8px; height:8px; border-radius:50%;
356
576
  background:var(--dim); display:inline-block;
@@ -381,6 +601,15 @@
381
601
  <div id="panelContent"></div>
382
602
  </div>
383
603
 
604
+ <div class="agent-panel" id="agentPanel">
605
+ <div class="agent-panel-header">
606
+ <span class="agent-panel-title" id="agentPanelTitle">🤖 Agent</span>
607
+ <span class="agent-panel-status" id="agentPanelStatus">running…</span>
608
+ <button class="agent-panel-close" onclick="closeAgentPanel()">✕</button>
609
+ </div>
610
+ <div class="agent-terminal" id="agentTerminal"></div>
611
+ </div>
612
+
384
613
  <script>
385
614
  // ─── State ────────────────────────────────────────────────────────────────────
386
615
  let D = null;
@@ -388,6 +617,79 @@ let view = 'features';
388
617
  let searchQuery = '';
389
618
  let activeTypes = new Set();
390
619
  let activePanelKey = null;
620
+ let drillFeatureKey = null; // null = grid, '__unmapped__' = unmapped, string = feature key
621
+ let drillTestType = null; // null = feature overview, 'unit'|'integration'|'e2e' = test type drill
622
+ let coverageRunning = false;
623
+ let coverageHasError = false;
624
+
625
+ // ─── Coverage button ───────────────────────────────────────────────────────────
626
+ function updateCovBtn() {
627
+ const btn = document.getElementById('covBtn');
628
+ if (!btn) return;
629
+ btn.className = '';
630
+ btn.disabled = coverageRunning;
631
+ if (coverageRunning) {
632
+ btn.className = 'cov-running';
633
+ btn.textContent = '⏳ Running...';
634
+ } else if (coverageHasError) {
635
+ btn.className = 'cov-error';
636
+ btn.textContent = '❌ Coverage failed';
637
+ } else {
638
+ btn.textContent = '🧪 Coverage';
639
+ }
640
+ }
641
+
642
+ async function runCoverage() {
643
+ if (coverageRunning) return;
644
+ await fetch('/api/run-coverage', { method: 'POST' });
645
+ // SSE events will update state
646
+ }
647
+
648
+ // ─── Agent ────────────────────────────────────────────────────────────────────
649
+ let agentRunning = false;
650
+
651
+ async function setAgent(agent) {
652
+ await fetch('/api/set-agent', {
653
+ method: 'POST',
654
+ headers: { 'Content-Type': 'application/json' },
655
+ body: JSON.stringify({ agent }),
656
+ });
657
+ // scheduleRescan will fire → data-updated → D.agent updates
658
+ }
659
+
660
+ async function runAgentTask(task, featureKey) {
661
+ if (agentRunning) return;
662
+ document.getElementById('agentTerminal').innerHTML = '';
663
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
664
+ document.getElementById('agentPanel').classList.add('open');
665
+ document.getElementById('termBtn').classList.add('term-active');
666
+ await fetch('/api/run-agent', {
667
+ method: 'POST',
668
+ headers: { 'Content-Type': 'application/json' },
669
+ body: JSON.stringify({ task, featureKey }),
670
+ });
671
+ }
672
+
673
+ function closeAgentPanel() {
674
+ document.getElementById('agentPanel').classList.remove('open');
675
+ document.getElementById('termBtn').classList.remove('term-active');
676
+ }
677
+
678
+ function toggleAgentPanel() {
679
+ const panel = document.getElementById('agentPanel');
680
+ const btn = document.getElementById('termBtn');
681
+ panel.classList.toggle('open');
682
+ btn.classList.toggle('term-active', panel.classList.contains('open'));
683
+ }
684
+
685
+ function appendTerminalLine(line, isError) {
686
+ const term = document.getElementById('agentTerminal');
687
+ const el = document.createElement('div');
688
+ el.className = 'agent-line' + (isError ? ' err' : '');
689
+ el.textContent = line;
690
+ term.appendChild(el);
691
+ term.scrollTop = term.scrollHeight;
692
+ }
391
693
 
392
694
  // ─── Color helpers ────────────────────────────────────────────────────────────
393
695
  const TYPE_COLORS = {
@@ -421,9 +723,19 @@ function pluralFiles(n) {
421
723
  // ─── Init ─────────────────────────────────────────────────────────────────────
422
724
  async function init() {
423
725
  try {
424
- const res = await fetch('/api/data');
726
+ const [res, statusRes] = await Promise.all([
727
+ fetch('/api/data'),
728
+ fetch('/api/status').catch(() => null),
729
+ ]);
425
730
  D = await res.json();
426
731
 
732
+ if (statusRes) {
733
+ const status = await statusRes.json().catch(() => ({}));
734
+ coverageRunning = status.coverageRunning ?? false;
735
+ coverageHasError = status.coverageError ?? false;
736
+ }
737
+ updateCovBtn();
738
+
427
739
  document.getElementById('projectName').textContent = D.projectName;
428
740
  document.getElementById('scannedAt').textContent =
429
741
  new Date(D.scannedAt).toLocaleTimeString();
@@ -452,7 +764,7 @@ function renderStats() {
452
764
 
453
765
  let items;
454
766
  if (D.hasConfig && D.features) {
455
- const unmapped = src.filter(m => !m.featureKeys || m.featureKeys.length === 0).length;
767
+ const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
456
768
  items = [
457
769
  { v: D.features.length, l: 'Features' },
458
770
  { v: src.length, l: 'Source Files' },
@@ -519,7 +831,28 @@ function renderSidebar() {
519
831
  // ─── Content ──────────────────────────────────────────────────────────────────
520
832
  function renderContent() {
521
833
  const c = document.getElementById('content');
522
- view === 'features' ? renderFeatureCards(c) : renderModuleGrid(c);
834
+ if (view === 'features') {
835
+ if (drillFeatureKey === '__unmapped__') renderUnmappedDetail(c);
836
+ else if (drillFeatureKey) renderFeatureDetail(c);
837
+ else renderFeatureCards(c);
838
+ } else {
839
+ renderModuleGrid(c);
840
+ }
841
+ }
842
+
843
+ function backToFeatureDetail() {
844
+ drillTestType = null;
845
+ activePanelKey = null;
846
+ document.getElementById('panel').classList.remove('open');
847
+ renderContent();
848
+ }
849
+
850
+ function backToFeatures() {
851
+ drillFeatureKey = null;
852
+ drillTestType = null;
853
+ activePanelKey = null;
854
+ document.getElementById('panel').classList.remove('open');
855
+ renderContent();
523
856
  }
524
857
 
525
858
  function renderFeatureCards(c) {
@@ -542,12 +875,25 @@ function renderFeatureCards(c) {
542
875
 
543
876
  if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
544
877
 
545
- c.innerHTML = '<div class="features-grid" id="featGrid"></div>';
878
+ // Agent setup banner — shown when no agent configured
879
+ const setupBanner = !D.agent ? `
880
+ <div class="agent-setup-banner">
881
+ <h3>🤖 Выбери AI агента</h3>
882
+ <p>VibeRadar будет запускать его прямо из дашборда — писать тесты, разбирать unmapped и не только</p>
883
+ <div class="agent-choices">
884
+ <button class="agent-choice-btn" onclick="setAgent('claude')">⚡ Claude Code</button>
885
+ <button class="agent-choice-btn" onclick="setAgent('codex')">🟢 Codex (OpenAI)</button>
886
+ </div>
887
+ </div>` : '';
888
+
889
+ c.innerHTML = setupBanner + '<div class="features-grid" id="featGrid"></div>';
546
890
  const grid = document.getElementById('featGrid');
547
891
 
548
892
  list.forEach(f => {
549
893
  const pct = f.fileCount > 0 ? Math.round(f.testedCount / f.fileCount * 100) : 0;
550
894
  const isActive = activePanelKey === f.key;
895
+ const hasCov = f.coveragePct != null;
896
+ const covPct = hasCov ? Math.round(f.coveragePct) : null;
551
897
 
552
898
  const card = document.createElement('div');
553
899
  card.className = 'feature-card' + (isActive ? ' active' : '');
@@ -559,21 +905,51 @@ function renderFeatureCards(c) {
559
905
  <span class="feature-file-count">${f.fileCount} ${pluralFiles(f.fileCount)}</span>
560
906
  </div>
561
907
  ${f.description ? `<div class="feature-desc">${f.description}</div>` : ''}
908
+ ${hasCov ? `
909
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
910
+ <span style="font-size:24px;font-weight:700;line-height:1;color:${covColor(covPct)}">${covPct}%</span>
911
+ <div style="flex:1">
912
+ <div style="font-size:10px;color:var(--muted);margin-bottom:3px;text-transform:uppercase;letter-spacing:0.4px">Coverage</div>
913
+ <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
914
+ <div style="width:${covPct}%;height:100%;background:${covColor(covPct)};border-radius:2px;transition:width 0.4s"></div>
915
+ </div>
916
+ </div>
917
+ </div>` : `
918
+ <div style="font-size:11px;color:var(--dim);margin-bottom:10px">
919
+ coverage: нет данных
920
+ </div>`}
562
921
  <div class="feature-progress-wrap">
563
922
  <div class="feature-progress-bar">
564
923
  <div class="feature-progress-fill" style="width:${pct}%;background:${f.color}"></div>
565
924
  </div>
566
- <span class="feature-progress-label" style="color:${covColor(pct)}">${f.testedCount}/${f.fileCount} ✓</span>
925
+ <span class="feature-progress-label" style="color:${covColor(pct)}">${f.testedCount}/${f.fileCount} с тестами</span>
567
926
  </div>
927
+ ${D.agent && f.fileCount > f.testedCount ? `
928
+ <button class="agent-card-btn" data-task="write-tests" data-key="${f.key}">
929
+ ▶ Написать тесты (${f.fileCount - f.testedCount} без тестов)
930
+ </button>` : ''}
568
931
  </div>`;
569
- card.onclick = () => openFeaturePanel(f.key);
932
+ card.onclick = (e) => {
933
+ if (e.target.closest('.agent-card-btn')) return; // don't drill on agent btn click
934
+ drillFeatureKey = f.key; activePanelKey = null;
935
+ document.getElementById('panel').classList.remove('open');
936
+ renderContent();
937
+ };
938
+ const agentBtn = card.querySelector('.agent-card-btn');
939
+ if (agentBtn) {
940
+ agentBtn.onclick = (e) => {
941
+ e.stopPropagation();
942
+ runAgentTask(agentBtn.dataset.task, agentBtn.dataset.key);
943
+ };
944
+ }
570
945
  grid.appendChild(card);
571
946
  });
572
947
 
573
948
  // ── Unmapped card ──────────────────────────────────────────────────────────
574
949
  if (!q) {
950
+ const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
575
951
  const unmappedSrc = D.modules.filter(m =>
576
- m.type !== 'test' && (!m.featureKeys || m.featureKeys.length === 0)
952
+ m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
577
953
  );
578
954
  if (unmappedSrc.length > 0) {
579
955
  const isActive = activePanelKey === '__unmapped__';
@@ -581,6 +957,9 @@ function renderFeatureCards(c) {
581
957
  card.className = 'feature-card' + (isActive ? ' active' : '');
582
958
  card.style.borderStyle = 'dashed';
583
959
  card.style.opacity = '0.75';
960
+ const infraNote = infraSrc.length > 0
961
+ ? `<br><span style="color:var(--dim);font-size:11px">+ ${infraSrc.length} infra/system скрыты</span>`
962
+ : '';
584
963
  card.innerHTML = `
585
964
  <div class="feature-accent" style="background:var(--yellow)"></div>
586
965
  <div class="feature-body">
@@ -588,7 +967,7 @@ function renderFeatureCards(c) {
588
967
  <span style="color:var(--yellow)">⚠ Unmapped</span>
589
968
  <span class="feature-file-count">${unmappedSrc.length} ${pluralFiles(unmappedSrc.length)}</span>
590
969
  </div>
591
- <div class="feature-desc">Файлы вне карты фич — не входят ни в одну фичу</div>
970
+ <div class="feature-desc">Файлы вне карты фич — не входят ни в одну фичу${infraNote}</div>
592
971
  <div class="feature-progress-wrap">
593
972
  <div class="feature-progress-bar">
594
973
  <div class="feature-progress-fill" style="width:100%;background:var(--border)"></div>
@@ -596,12 +975,261 @@ function renderFeatureCards(c) {
596
975
  <span class="feature-progress-label" style="color:var(--dim)">нет привязки</span>
597
976
  </div>
598
977
  </div>`;
599
- card.onclick = () => openUnmappedPanel(unmappedSrc);
978
+ card.onclick = () => {
979
+ drillFeatureKey = '__unmapped__';
980
+ activePanelKey = null;
981
+ document.getElementById('panel').classList.remove('open');
982
+ renderContent();
983
+ };
600
984
  grid.appendChild(card);
601
985
  }
602
986
  }
603
987
  }
604
988
 
989
+ function testTypeCard(type, label, icon, color, count, active) {
990
+ const empty = count === 0 && type !== 'source';
991
+ const subLabel = empty ? 'нет тестов' : (type === 'source' ? 'код приложения' : pluralFiles(count));
992
+ return `
993
+ <div class="test-type-card${empty ? ' tt-empty' : ''}${active ? ' tt-active' : ''}" data-testtype="${type}"
994
+ style="${active ? 'border-color:' + color : ''}">
995
+ <div class="tt-accent" style="background:${color}"></div>
996
+ <div class="tt-label">${icon} ${label}</div>
997
+ <div class="tt-count" style="color:${active || !empty ? color : 'var(--dim)'}">${count}</div>
998
+ <div class="tt-sub">${subLabel}</div>
999
+ </div>`;
1000
+ }
1001
+
1002
+ function renderFeatureDetail(c) {
1003
+ const feat = D.features.find(f => f.key === drillFeatureKey);
1004
+ if (!feat) { backToFeatures(); return; }
1005
+
1006
+ const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(drillFeatureKey));
1007
+ const src = mods.filter(m => m.type !== 'test');
1008
+ const tst = mods.filter(m => m.type === 'test');
1009
+ const testedCount = src.filter(m => m.hasTests).length;
1010
+ const pct = src.length > 0 ? Math.round(testedCount / src.length * 100) : 0;
1011
+
1012
+ const unitCount = feat.unitTestCount ?? tst.filter(m => m.testType === 'unit').length;
1013
+ const integrationCount = feat.integrationTestCount ?? tst.filter(m => m.testType === 'integration').length;
1014
+ const e2eCount = feat.e2eTestCount ?? tst.filter(m => m.testType === 'e2e').length;
1015
+
1016
+ // Determine what list to show based on active tab
1017
+ // null or 'source' → source files; test type → test files of that type
1018
+ const activeTab = drillTestType || 'source';
1019
+ const listFiles = activeTab === 'source' ? src : tst.filter(m => m.testType === activeTab);
1020
+ const isTestList = activeTab !== 'source';
1021
+ const meta = TEST_TYPE_META[activeTab];
1022
+ const listLabel = meta
1023
+ ? `${meta.icon} ${meta.label} тесты (${listFiles.length})`
1024
+ : `📁 Файлы фичи (${listFiles.length})`;
1025
+
1026
+ const q = searchQuery.toLowerCase();
1027
+ const filtered = q ? listFiles.filter(m =>
1028
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
1029
+ ) : listFiles;
1030
+
1031
+ c.innerHTML = `
1032
+ <div class="drill-header">
1033
+ <button class="back-btn" onclick="backToFeatures()">← Все фичи</button>
1034
+ <div class="drill-title">
1035
+ <div style="width:10px;height:10px;border-radius:50%;background:${feat.color};flex-shrink:0"></div>
1036
+ <span>${feat.label}</span>
1037
+ </div>
1038
+ <div class="drill-stats">
1039
+ <span>${src.length} файлов</span>
1040
+ <span style="color:${covColor(pct)}">${pct}% с тестами</span>
1041
+ ${feat.coveragePct != null
1042
+ ? `<span style="color:${covColor(Math.round(feat.coveragePct))};font-weight:700">${Math.round(feat.coveragePct)}% coverage</span>`
1043
+ : `<span style="color:var(--dim);font-size:11px" title="Нажми 🧪 Coverage в шапке">coverage: нет данных</span>`
1044
+ }
1045
+ </div>
1046
+ </div>
1047
+ ${feat.description ? `<div class="drill-desc">${feat.description}</div>` : ''}
1048
+
1049
+ <div class="test-type-grid">
1050
+ ${testTypeCard('source', 'Файлы', '📁', feat.color, src.length, activeTab === 'source')}
1051
+ ${testTypeCard('unit', 'Unit', '🧪', '#e3b341', unitCount, activeTab === 'unit')}
1052
+ ${testTypeCard('integration', 'Integration', '🔗', '#58a6ff', integrationCount, activeTab === 'integration')}
1053
+ ${testTypeCard('e2e', 'E2E', '🎭', '#d2a8ff', e2eCount, activeTab === 'e2e')}
1054
+ </div>
1055
+
1056
+ <div class="drill-section-label">${listLabel}</div>
1057
+ <div class="file-rows" id="fileRows">
1058
+ ${filtered.length === 0
1059
+ ? `<div style="padding:20px;text-align:center;border:1px dashed var(--border);border-radius:8px;color:var(--dim);font-size:13px">
1060
+ ${isTestList ? 'Нет тестов этого типа для данной фичи' : 'Нет файлов — возможно паттерны в конфиге не совпадают'}
1061
+ </div>`
1062
+ : filtered.map(m => fileRow(m, isTestList)).join('')
1063
+ }
1064
+ </div>`;
1065
+
1066
+ c.querySelectorAll('.test-type-card[data-testtype]').forEach(card => {
1067
+ card.onclick = () => {
1068
+ const type = card.dataset.testtype;
1069
+ drillTestType = (type === 'source') ? null : type; // 'source' tab = null state
1070
+ activePanelKey = null;
1071
+ document.getElementById('panel').classList.remove('open');
1072
+ renderContent();
1073
+ };
1074
+ });
1075
+
1076
+ c.querySelectorAll('.file-row[data-id]').forEach(row => {
1077
+ row.onclick = () => {
1078
+ const m = D.modules.find(m => m.id === row.dataset.id);
1079
+ if (m) openModulePanel(m);
1080
+ };
1081
+ });
1082
+ }
1083
+
1084
+ const TEST_TYPE_META = {
1085
+ unit: { label: 'Unit', icon: '🧪', color: '#e3b341', desc: 'Изолированные тесты функций и модулей' },
1086
+ integration: { label: 'Integration', icon: '🔗', color: '#58a6ff', desc: 'Тесты с реальной БД и зависимостями' },
1087
+ e2e: { label: 'E2E', icon: '🎭', color: '#d2a8ff', desc: 'Сквозные тесты через браузер (Playwright)' },
1088
+ };
1089
+
1090
+ function renderTestTypeDetail(c) {
1091
+ const feat = D.features.find(f => f.key === drillFeatureKey);
1092
+ if (!feat) { backToFeatures(); return; }
1093
+ const meta = TEST_TYPE_META[drillTestType] || { label: drillTestType, icon: '🧪', color: '#58a6ff', desc: '' };
1094
+
1095
+ const tests = D.modules.filter(m =>
1096
+ m.type === 'test' &&
1097
+ m.testType === drillTestType &&
1098
+ m.featureKeys && m.featureKeys.includes(drillFeatureKey)
1099
+ );
1100
+
1101
+ const q = searchQuery.toLowerCase();
1102
+ const filtered = q ? tests.filter(m =>
1103
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
1104
+ ) : tests;
1105
+
1106
+ c.innerHTML = `
1107
+ <div class="drill-header">
1108
+ <button class="back-btn" onclick="backToFeatureDetail()">← ${feat.label}</button>
1109
+ <div class="drill-title">
1110
+ <span>${meta.icon}</span>
1111
+ <span>${meta.label} тесты</span>
1112
+ </div>
1113
+ <div class="drill-stats">
1114
+ <span style="color:${meta.color}">${tests.length} ${pluralFiles(tests.length)}</span>
1115
+ </div>
1116
+ </div>
1117
+ <div style="font-size:12px;color:var(--dim);margin-bottom:16px">${meta.desc}</div>
1118
+
1119
+ <div class="file-rows" id="fileRows">
1120
+ ${filtered.length === 0
1121
+ ? `<div style="padding:24px;text-align:center;border:1px dashed var(--border);border-radius:8px">
1122
+ <div style="font-size:28px;margin-bottom:8px">${meta.icon}</div>
1123
+ <div style="font-size:14px;color:var(--muted);margin-bottom:4px">Нет ${meta.label} тестов для этой фичи</div>
1124
+ <div style="font-size:12px;color:var(--dim)">Добавь тесты в <code>${drillTestType === 'e2e' ? 'e2e/' : 'tests/'}</code></div>
1125
+ </div>`
1126
+ : filtered.map(m => fileRow(m, true)).join('')
1127
+ }
1128
+ </div>`;
1129
+
1130
+ c.querySelectorAll('.file-row[data-id]').forEach(row => {
1131
+ row.onclick = () => {
1132
+ const m = D.modules.find(m => m.id === row.dataset.id);
1133
+ if (m) openModulePanel(m);
1134
+ };
1135
+ });
1136
+ }
1137
+
1138
+ function renderUnmappedDetail(c) {
1139
+ const infraSrc = D.modules.filter(m => m.type !== 'test' && m.isInfra);
1140
+ const unmappedSrc = D.modules.filter(m =>
1141
+ m.type !== 'test' && !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)
1142
+ );
1143
+
1144
+ const q = searchQuery.toLowerCase();
1145
+ const filtered = q ? unmappedSrc.filter(m =>
1146
+ m.name.toLowerCase().includes(q) || m.relativePath.toLowerCase().includes(q)
1147
+ ) : unmappedSrc;
1148
+
1149
+ // Build prompt text
1150
+ const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
1151
+ const plainList = unmappedSrc.map(m => '- ' + m.relativePath.replace(/\\/g, '/')).join('\n');
1152
+ const promptText =
1153
+ `В проекте ${unmappedSrc.length} файлов без привязки к фичам (unmapped).\n` +
1154
+ `\nДля каждого файла из списка реши:\n` +
1155
+ `1. Если файл относится к конкретной фиче → добавь его путь в "include" этой фичи в viberadar.config.json\n` +
1156
+ `2. Если это инфраструктура (утилиты, конфиги, middleware, типы, бутстрап) → добавь glob в массив "ignore"\n` +
1157
+ `3. Если файл явно бизнес-логика новой фичи → создай новую фичу\n` +
1158
+ `4. Если непонятно — пропусти\n` +
1159
+ `\nСуществующие фичи:\n${featureList}\n` +
1160
+ `\nФайлы:\n${plainList}`;
1161
+
1162
+ const infraNote = infraSrc.length > 0
1163
+ ? `<span style="color:var(--dim);font-size:12px">+ ${infraSrc.length} infra/system скрыты (в ignore)</span>`
1164
+ : '';
1165
+
1166
+ c.innerHTML = `
1167
+ <div class="drill-header">
1168
+ <button class="back-btn" onclick="backToFeatures()">← Все фичи</button>
1169
+ <div class="drill-title">
1170
+ <span style="color:var(--yellow)">⚠</span>
1171
+ <span>Unmapped файлы</span>
1172
+ </div>
1173
+ <div class="drill-stats">
1174
+ <span style="color:var(--yellow)">${unmappedSrc.length} без привязки</span>
1175
+ ${infraNote}
1176
+ </div>
1177
+ </div>
1178
+ <div style="padding:0 0 12px;display:flex;gap:8px;flex-wrap:wrap">
1179
+ ${D.agent ? `<button id="runAgentUnmapped" style="
1180
+ padding:7px 14px; background:var(--blue); border:none;
1181
+ border-radius:6px; color:#000; font-size:12px; font-weight:700; cursor:pointer;
1182
+ ">▶ Разобрать через ${D.agent === 'claude' ? 'Claude Code' : 'Codex'}</button>` : ''}
1183
+ <button id="copyUnmappedDrill" style="
1184
+ padding:7px 14px; background:var(--bg-card); border:1px solid var(--border);
1185
+ border-radius:6px; color:var(--blue); font-size:12px; cursor:pointer;
1186
+ ">📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)</button>
1187
+ </div>
1188
+ <div class="file-rows" id="fileRows">
1189
+ ${filtered.length === 0
1190
+ ? '<div style="font-size:13px;color:var(--dim)">Ничего не найдено</div>'
1191
+ : filtered.map(m => fileRow(m)).join('')
1192
+ }
1193
+ </div>`;
1194
+
1195
+ const runAgentUnmappedBtn = document.getElementById('runAgentUnmapped');
1196
+ if (runAgentUnmappedBtn) {
1197
+ runAgentUnmappedBtn.onclick = () => runAgentTask('map-unmapped');
1198
+ }
1199
+
1200
+ document.getElementById('copyUnmappedDrill').onclick = function() {
1201
+ navigator.clipboard.writeText(promptText).then(() => {
1202
+ this.textContent = '✅ Скопировано!';
1203
+ this.style.color = 'var(--green)';
1204
+ setTimeout(() => {
1205
+ this.textContent = `📋 Скопировать промпт для AI-агента (${unmappedSrc.length} файлов)`;
1206
+ this.style.color = 'var(--blue)';
1207
+ }, 3000);
1208
+ });
1209
+ };
1210
+
1211
+ c.querySelectorAll('.file-row[data-id]').forEach(row => {
1212
+ row.onclick = () => {
1213
+ const m = D.modules.find(m => m.id === row.dataset.id);
1214
+ if (m) openModulePanel(m);
1215
+ };
1216
+ });
1217
+ }
1218
+
1219
+ function fileRow(m, isTest = false) {
1220
+ const parts = m.relativePath.replace(/\\/g, '/').split('/');
1221
+ const name = parts[parts.length - 1];
1222
+ const dir = parts.slice(0, -1).join('/');
1223
+ const icon = isTest ? '🧪' : (m.hasTests ? '✅' : '⬜');
1224
+ const isActive = activePanelKey === m.id;
1225
+ return `
1226
+ <div class="file-row${isActive ? ' active' : ''}" data-id="${m.id}">
1227
+ <span class="file-row-icon">${icon}</span>
1228
+ <span class="file-row-name">${name}</span>
1229
+ <span class="file-row-dir">${dir}</span>
1230
+ </div>`;
1231
+ }
1232
+
605
1233
  function renderModuleGrid(c) {
606
1234
  const q = searchQuery.toLowerCase();
607
1235
  const list = D.modules.filter(m => {
@@ -757,7 +1385,8 @@ function openModulePanel(m) {
757
1385
  document.getElementById('panel').classList.add('open');
758
1386
  }
759
1387
 
760
- function openUnmappedPanel(files) {
1388
+ function openUnmappedPanel(files, infraFiles) {
1389
+ infraFiles = infraFiles || [];
761
1390
  activePanelKey = '__unmapped__';
762
1391
  renderContent();
763
1392
 
@@ -771,6 +1400,9 @@ function openUnmappedPanel(files) {
771
1400
  });
772
1401
  const dirs = Object.keys(byDir).sort();
773
1402
 
1403
+ // Build feature list for context
1404
+ const featureList = (D.features || []).map(f => ` • ${f.key} — ${f.label}`).join('\n');
1405
+
774
1406
  // Build plain-text list for copying to AI agent
775
1407
  const plainList = files
776
1408
  .map(m => '- ' + m.relativePath.replace(/\\/g, '/'))
@@ -778,26 +1410,38 @@ function openUnmappedPanel(files) {
778
1410
 
779
1411
  const promptText =
780
1412
  `В проекте ${files.length} файлов без привязки к фичам (unmapped).\n` +
781
- `Изучи список ниже и обнови viberadar.config.json:\n` +
782
- `добавь эти файлы в существующие фичи или создай новые.\n\n` +
783
- plainList;
1413
+ `\nДля каждого файла из списка реши:\n` +
1414
+ `1. Если файл относится к конкретной фиче → добавь его путь в "include" этой фичи в viberadar.config.json\n` +
1415
+ `2. Если это инфраструктура (утилиты, конфиги, middleware, типы, бутстрап) → добавь glob в массив "ignore"\n` +
1416
+ `3. Если файл явно бизнес-логика новой фичи → создай новую фичу\n` +
1417
+ `4. Если непонятно — пропусти\n` +
1418
+ `\nСуществующие фичи:\n${featureList}\n` +
1419
+ `\nФайлы:\n${plainList}`;
1420
+
1421
+ const infraNote = infraFiles.length > 0
1422
+ ? `<div style="margin-bottom:12px;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;color:var(--dim)">
1423
+ 🔒 <b style="color:var(--text)">${infraFiles.length} infra/system файлов</b> скрыты (добавлены в <code>ignore</code>)<br>
1424
+ Они не считаются unmapped и не показываются в карте фич.
1425
+ </div>`
1426
+ : '';
784
1427
 
785
1428
  document.getElementById('panelContent').innerHTML = `
786
1429
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
787
1430
  <span style="font-size:16px">⚠</span>
788
1431
  <div class="panel-title" style="color:var(--yellow)">Unmapped файлы</div>
789
1432
  </div>
790
- <div class="panel-subtitle">
791
- Не входят ни в одну фичу.<br>
792
- Запусти <code style="color:var(--blue)">npx viberadar init</code> — агент предложит куда добавить.
1433
+ <div class="panel-subtitle" style="margin-bottom:12px">
1434
+ Не входят ни в одну фичу. Скопируй список и отправь AI-агенту.
793
1435
  </div>
794
1436
 
1437
+ ${infraNote}
1438
+
795
1439
  <button id="copyUnmapped" style="
796
1440
  width:100%; padding:8px 12px; margin-bottom:16px;
797
1441
  background:var(--bg); border:1px solid var(--border);
798
1442
  border-radius:6px; color:var(--blue); font-size:12px;
799
1443
  cursor:pointer; text-align:left;
800
- ">📋 Скопировать список для AI-агента</button>
1444
+ ">📋 Скопировать промпт для AI-агента (${files.length} файлов)</button>
801
1445
 
802
1446
  ${dirs.map(dir => `
803
1447
  <div class="panel-section">
@@ -833,6 +1477,8 @@ document.querySelectorAll('.view-tab').forEach(tab => {
833
1477
  tab.onclick = () => {
834
1478
  if (tab.classList.contains('disabled')) return;
835
1479
  view = tab.dataset.view;
1480
+ drillFeatureKey = null;
1481
+ drillTestType = null;
836
1482
  activePanelKey = null;
837
1483
  searchQuery = '';
838
1484
  activeTypes.clear();
@@ -871,14 +1517,11 @@ async function refreshData() {
871
1517
  renderSidebar();
872
1518
  renderContent();
873
1519
 
874
- // Re-open panel if it was open
1520
+ // Re-render drill-down or re-open panel
875
1521
  const panelOpen = document.getElementById('panel').classList.contains('open');
876
1522
  if (panelOpen && activePanelKey) {
877
- if (activePanelKey === '__unmapped__') {
878
- const unmapped = D.modules.filter(m =>
879
- m.type !== 'test' && (!m.featureKeys || m.featureKeys.length === 0)
880
- );
881
- openUnmappedPanel(unmapped);
1523
+ if (drillFeatureKey === '__unmapped__') {
1524
+ renderContent(); // already routes to renderUnmappedDetail
882
1525
  } else if (view === 'features' && D.features) {
883
1526
  openFeaturePanel(activePanelKey);
884
1527
  } else {
@@ -906,6 +1549,78 @@ function connectSSE() {
906
1549
  refreshData();
907
1550
  });
908
1551
 
1552
+ es.addEventListener('coverage-started', () => {
1553
+ coverageRunning = true;
1554
+ coverageHasError = false;
1555
+ updateCovBtn();
1556
+ });
1557
+
1558
+ es.addEventListener('coverage-done', () => {
1559
+ coverageRunning = false;
1560
+ coverageHasError = false;
1561
+ updateCovBtn();
1562
+ // data-updated fires separately and triggers refreshData()
1563
+ });
1564
+
1565
+ es.addEventListener('coverage-error', () => {
1566
+ coverageRunning = false;
1567
+ coverageHasError = true;
1568
+ updateCovBtn();
1569
+ setTimeout(() => { coverageHasError = false; updateCovBtn(); }, 8000);
1570
+ });
1571
+
1572
+ es.addEventListener('agent-started', (e) => {
1573
+ agentRunning = true;
1574
+ const { title } = JSON.parse(e.data);
1575
+ document.getElementById('agentPanelTitle').textContent = '🤖 ' + title;
1576
+ document.getElementById('agentPanelStatus').textContent = 'запускаю…';
1577
+ document.getElementById('agentPanel').classList.add('open');
1578
+ document.getElementById('termBtn').classList.add('term-active');
1579
+ document.getElementById('agentTerminal').innerHTML = '';
1580
+ });
1581
+
1582
+ es.addEventListener('agent-output', (e) => {
1583
+ const { line, isError } = JSON.parse(e.data);
1584
+ appendTerminalLine(line, !!isError);
1585
+ document.getElementById('agentPanelStatus').textContent = 'работает…';
1586
+ });
1587
+
1588
+ es.addEventListener('agent-done', () => {
1589
+ agentRunning = false;
1590
+ document.getElementById('agentPanelStatus').textContent = '✅ готово';
1591
+ renderContent();
1592
+ });
1593
+
1594
+ es.addEventListener('agent-summary', (e) => {
1595
+ const { passed, failed, files } = JSON.parse(e.data);
1596
+ const term = document.getElementById('agentTerminal');
1597
+ const allOk = failed === 0;
1598
+ const box = document.createElement('div');
1599
+ box.style.cssText = `
1600
+ margin: 10px 0 4px;
1601
+ padding: 10px 14px;
1602
+ border-radius: 8px;
1603
+ border: 1px solid ${allOk ? 'var(--green)' : 'var(--red)'};
1604
+ background: ${allOk ? '#0d2a1a' : '#2a0d0d'};
1605
+ font-family: inherit;
1606
+ `;
1607
+ box.innerHTML = `
1608
+ <div style="font-size:13px;font-weight:700;color:${allOk ? 'var(--green)' : 'var(--red)'}">
1609
+ ${allOk ? '✅' : '⚠️'} Тесты: ${passed} passed${failed > 0 ? ', ' + failed + ' failed' : ''}
1610
+ </div>
1611
+ ${files.map(f => `<div style="font-size:11px;color:var(--dim);margin-top:3px">📄 ${f}</div>`).join('')}
1612
+ `;
1613
+ term.appendChild(box);
1614
+ term.scrollTop = term.scrollHeight;
1615
+ });
1616
+
1617
+ es.addEventListener('agent-error', (e) => {
1618
+ agentRunning = false;
1619
+ const { message } = JSON.parse(e.data);
1620
+ document.getElementById('agentPanelStatus').textContent = '❌ ошибка';
1621
+ appendTerminalLine('❌ ' + (message || 'Ошибка агента'), true);
1622
+ });
1623
+
909
1624
  es.onerror = () => {
910
1625
  setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
911
1626
  es.close();