reactoradar 1.2.5 → 1.4.1

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/app.js CHANGED
@@ -6,7 +6,7 @@ const state = {
6
6
  activePanel: 'console',
7
7
  ports: {},
8
8
 
9
- console: { logs: [], levelFilter: 'all', searchFilter: '', stackTraceEnabled: false },
9
+ console: { logs: [], levelFilters: { log: true, info: true, warn: true, error: true, debug: true }, searchFilter: '' },
10
10
 
11
11
  network: {
12
12
  requests: {},
@@ -106,8 +106,7 @@ document.querySelectorAll('.nav-btn').forEach(btn => {
106
106
 
107
107
  // Global filter removed — each panel has its own search input
108
108
 
109
- // ─── Clear (active tab only) ──────────────────────────────────────────────────
110
- $('btnClear').addEventListener('click', clearActiveTab);
109
+ // ─── Clear (each panel has its own clear button now) ─────────────────────────
111
110
 
112
111
  function clearActiveTab() {
113
112
  switch (state.activePanel) {
@@ -139,6 +138,13 @@ function clearActiveTab() {
139
138
  $('sBadge').textContent = '0';
140
139
  renderStorage();
141
140
  break;
141
+ case 'ga4':
142
+ ga4State.events = [];
143
+ ga4State.selected = -1;
144
+ $('ga4Badge').textContent = '0';
145
+ renderGA4List();
146
+ renderGA4Summary();
147
+ break;
142
148
  case 'performance':
143
149
  perfState.fps = [];
144
150
  perfState.jsThread = [];
@@ -213,6 +219,8 @@ if (window.electronAPI) {
213
219
  window.electronAPI.on('network-event', handleNetworkEvent);
214
220
  window.electronAPI.on('storage-event', handleStorageEvent);
215
221
 
222
+ window.electronAPI.on('ga4-event', handleGA4Event);
223
+
216
224
  window.electronAPI.on('perf-event', event => {
217
225
  handlePerfEvent(event);
218
226
  handleMemoryEvent(event);
@@ -285,26 +293,40 @@ function updateDeviceBanner(service, connected) {
285
293
  // ─────────────────────────────────────────────────────────────────────────────
286
294
  // CONSOLE PANEL
287
295
  // ─────────────────────────────────────────────────────────────────────────────
296
+ // Load saved log level filters from localStorage
297
+ function getStoredLogLevels() {
298
+ try {
299
+ const saved = localStorage.getItem('rn-debug-log-levels');
300
+ if (saved) return JSON.parse(saved);
301
+ } catch {}
302
+ return { log: true, info: true, warn: true, error: true, debug: true };
303
+ }
304
+ function setStoredLogLevels(levels) {
305
+ try { localStorage.setItem('rn-debug-log-levels', JSON.stringify(levels)); } catch {}
306
+ }
307
+
288
308
  function initConsolePanel() {
289
309
  const panel = $('panel-console');
310
+ const levels = getStoredLogLevels();
311
+ state.console.levelFilters = levels;
312
+
290
313
  panel.innerHTML = `
291
314
  <div class="panel-toolbar">
292
315
  <span class="panel-label">Console</span>
293
316
  <span class="badge" id="cBadge">0</span>
294
- <div class="tab-row" style="margin-left:12px">
295
- <button class="tab active" onclick="setConsoleLevel('all',this)">All</button>
296
- <button class="tab" onclick="setConsoleLevel('log',this)">Log</button>
297
- <button class="tab" onclick="setConsoleLevel('info',this)">Info</button>
298
- <button class="tab" onclick="setConsoleLevel('warn',this)">Warn</button>
299
- <button class="tab" onclick="setConsoleLevel('error',this)">Error</button>
300
- </div>
301
- <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
302
- <input id="consoleSearch" class="net-search-input" placeholder="Filter logs..." />
303
- <label class="toggle-label" for="stackTraceToggle" title="Capture stack trace (caller file:line) disabled by default for performance">
304
- <span class="toggle-text" id="stackTraceText">Stack OFF</span>
305
- <input type="checkbox" id="stackTraceToggle" class="toggle-input" />
306
- <span class="toggle-slider"></span>
307
- </label>
317
+ <input id="consoleSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter logs..." />
318
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
319
+ <button class="panel-clear-btn" id="consoleClear" title="Clear console">Clear</button>
320
+ <div class="console-level-dropdown" id="consoleLevelDropdown">
321
+ <button class="console-level-btn" id="consoleLevelBtn">Levels ▾</button>
322
+ <div class="console-level-menu" id="consoleLevelMenu">
323
+ <label class="console-level-option"><input type="checkbox" data-level="log" ${levels.log ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--text-mid)"></span>Log</label>
324
+ <label class="console-level-option"><input type="checkbox" data-level="info" ${levels.info ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--accent)"></span>Info</label>
325
+ <label class="console-level-option"><input type="checkbox" data-level="warn" ${levels.warn ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--yellow)"></span>Warn</label>
326
+ <label class="console-level-option"><input type="checkbox" data-level="error" ${levels.error ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--red)"></span>Error</label>
327
+ <label class="console-level-option"><input type="checkbox" data-level="debug" ${levels.debug ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--accent2)"></span>Debug</label>
328
+ </div>
329
+ </div>
308
330
  </div>
309
331
  </div>
310
332
  <div class="scroll-area" id="consoleList">
@@ -315,25 +337,60 @@ function initConsolePanel() {
315
337
  </div>
316
338
  </div>`;
317
339
 
340
+ // Search filter
318
341
  $('consoleSearch').addEventListener('input', (e) => {
319
342
  state.console.searchFilter = e.target.value.toLowerCase().trim();
320
343
  renderConsole();
321
344
  });
322
345
 
323
- $('stackTraceToggle').addEventListener('change', (e) => {
324
- const enabled = e.target.checked;
325
- state.console.stackTraceEnabled = enabled;
326
- $('stackTraceText').textContent = enabled ? 'Stack ON' : 'Stack OFF';
327
- // Tell the SDK to enable/disable stack capture
328
- window.electronAPI?.setStackTraceCapture(enabled);
346
+ // Level dropdown toggle
347
+ $('consoleLevelBtn').addEventListener('click', (e) => {
348
+ e.stopPropagation();
349
+ $('consoleLevelMenu').classList.toggle('open');
350
+ });
351
+
352
+ // Close dropdown when clicking outside
353
+ document.addEventListener('click', (e) => {
354
+ if (!e.target.closest('#consoleLevelDropdown')) {
355
+ $('consoleLevelMenu')?.classList.remove('open');
356
+ }
357
+ });
358
+
359
+ // Level checkbox changes
360
+ $('consoleLevelMenu').addEventListener('change', (e) => {
361
+ const checkbox = e.target;
362
+ const level = checkbox.dataset.level;
363
+ if (level) {
364
+ state.console.levelFilters[level] = checkbox.checked;
365
+ setStoredLogLevels(state.console.levelFilters);
366
+ updateLevelBtnText();
367
+ renderConsole();
368
+ }
369
+ });
370
+
371
+ updateLevelBtnText();
372
+
373
+ $('consoleClear').addEventListener('click', () => {
374
+ state.console.logs = [];
375
+ _consolePending = [];
376
+ $('cBadge').textContent = '0';
377
+ renderConsole();
329
378
  });
330
379
  }
331
- window.setConsoleLevel = (level, btn) => {
332
- state.console.levelFilter = level;
333
- document.querySelectorAll('#panel-console .tab').forEach(b => b.classList.remove('active'));
334
- btn.classList.add('active');
335
- renderConsole();
336
- };
380
+
381
+ function updateLevelBtnText() {
382
+ const levels = state.console.levelFilters;
383
+ const allOn = Object.values(levels).every(v => v);
384
+ const allOff = Object.values(levels).every(v => !v);
385
+ const btn = $('consoleLevelBtn');
386
+ if (!btn) return;
387
+ if (allOn) btn.textContent = 'All Levels ▾';
388
+ else if (allOff) btn.textContent = 'None ▾';
389
+ else {
390
+ const active = Object.entries(levels).filter(([, v]) => v).map(([k]) => k.charAt(0).toUpperCase() + k.slice(1));
391
+ btn.textContent = active.join(', ') + ' ▾';
392
+ }
393
+ }
337
394
 
338
395
  // Console is fed via IPC (network-event handled in IPC section above)
339
396
 
@@ -362,12 +419,12 @@ function flushConsoleBatch() {
362
419
  const empty = $('consoleEmpty');
363
420
  if (!list) return;
364
421
 
365
- const { levelFilter, searchFilter } = state.console;
422
+ const { levelFilters, searchFilter } = state.console;
366
423
  const frag = document.createDocumentFragment();
367
424
  let added = 0;
368
425
 
369
426
  batch.forEach(l => {
370
- if (levelFilter !== 'all' && l.level !== levelFilter) return;
427
+ if (levelFilters && !levelFilters[l.level]) return;
371
428
  if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return;
372
429
  frag.appendChild(buildLogRow(l));
373
430
  added++;
@@ -634,27 +691,23 @@ function buildLogRow(l) {
634
691
  lvlSpan.textContent = l.level;
635
692
  div.appendChild(lvlSpan);
636
693
 
637
- // Body wrapper with preview (collapsed) and full (expanded)
694
+ // Arrow (inline, not inside body-wrap)
695
+ const arrow = document.createElement('span');
696
+ arrow.className = 'log-arrow';
697
+ arrow.textContent = '\u25B6';
698
+ div.appendChild(arrow);
699
+
700
+ // Body wrapper
638
701
  const bodyWrap = document.createElement('div');
639
702
  bodyWrap.className = 'log-body-wrap';
640
703
 
641
- // Single-line preview with caller at end
704
+ // Single-line preview: message text + caller
642
705
  const preview = document.createElement('div');
643
706
  preview.className = 'log-preview';
644
707
  const msgText = (l.message || '').replace(/\n/g, ' ').slice(0, 200);
645
708
  const previewText = document.createElement('span');
646
709
  previewText.textContent = msgText + ((l.message || '').length > 200 ? '...' : '');
647
710
  preview.appendChild(previewText);
648
- if (l.caller) {
649
- // Extract short filename:line from caller like "at Component (file.js:42:10)"
650
- const callerShort = extractCallerShort(l.caller);
651
- if (callerShort) {
652
- const callerTag = document.createElement('span');
653
- callerTag.className = 'log-caller-inline';
654
- callerTag.textContent = callerShort;
655
- preview.appendChild(callerTag);
656
- }
657
- }
658
711
  bodyWrap.appendChild(preview);
659
712
 
660
713
  // Full content (hidden by default)
@@ -662,20 +715,8 @@ function buildLogRow(l) {
662
715
  full.className = 'log-full';
663
716
  full.style.display = 'none';
664
717
  full.appendChild(buildLogBody(l));
665
- if (l.caller) {
666
- const callerSpan = document.createElement('span');
667
- callerSpan.className = 'log-caller';
668
- callerSpan.textContent = l.caller;
669
- full.appendChild(callerSpan);
670
- }
671
718
  bodyWrap.appendChild(full);
672
719
 
673
- // Expand/collapse arrow
674
- const arrow = document.createElement('span');
675
- arrow.className = 'log-arrow';
676
- arrow.textContent = '\u25B6';
677
- bodyWrap.prepend(arrow);
678
-
679
720
  let expanded = false;
680
721
  // Only toggle on click, NOT on text selection drag
681
722
  let _mouseDownPos = null;
@@ -774,9 +815,9 @@ function renderConsole() {
774
815
  const empty = $('consoleEmpty');
775
816
  if (!list) return;
776
817
 
777
- const { levelFilter, searchFilter } = state.console;
818
+ const { levelFilters, searchFilter } = state.console;
778
819
  const visible = state.console.logs.filter(l => {
779
- if (levelFilter !== 'all' && l.level !== levelFilter) return false;
820
+ if (levelFilters && !levelFilters[l.level]) return false;
780
821
  if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return false;
781
822
  return true;
782
823
  });
@@ -805,13 +846,13 @@ function renderConsole() {
805
846
  // NETWORK PANEL (Chrome DevTools-style)
806
847
  // ─────────────────────────────────────────────────────────────────────────────
807
848
  const NET_COLS = [
808
- { key: 'name', label: 'Name', width: 260, min: 100 },
849
+ { key: 'name', label: 'Name', width: 380, min: 150 },
809
850
  { key: 'status', label: 'Status', width: 60, min: 40 },
810
851
  { key: 'type', label: 'Type', width: 70, min: 40 },
811
- { key: 'initiator', label: 'Initiator', width: 90, min: 50 },
812
- { key: 'size', label: 'Size', width: 70, min: 40 },
813
- { key: 'time', label: 'Time', width: 70, min: 40 },
814
- { key: 'waterfall', label: 'Waterfall', width: 120, min: 60 },
852
+ { key: 'initiator', label: 'Initiator', width: 80, min: 50 },
853
+ { key: 'size', label: 'Size', width: 65, min: 40 },
854
+ { key: 'time', label: 'Time', width: 65, min: 40 },
855
+ { key: 'waterfall', label: 'Waterfall', width: 100, min: 60 },
815
856
  ];
816
857
 
817
858
  function initNetworkPanel() {
@@ -821,6 +862,7 @@ function initNetworkPanel() {
821
862
  <span class="panel-label">Network</span>
822
863
  <span class="badge" id="nBadge">0</span>
823
864
  <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
865
+ <button class="panel-clear-btn" id="networkClear" title="Clear network">Clear</button>
824
866
  <label class="toggle-label" for="netToggle">
825
867
  <span class="toggle-text" id="netToggleText">Capture ON</span>
826
868
  <input type="checkbox" id="netToggle" class="toggle-input" checked />
@@ -899,6 +941,16 @@ function initNetworkPanel() {
899
941
  window.electronAPI?.setNetworkThrottle(state.network.throttle);
900
942
  });
901
943
 
944
+ // Clear network
945
+ $('networkClear').addEventListener('click', () => {
946
+ state.network.requests = {};
947
+ state.network.order = [];
948
+ state.network.selectedId = null;
949
+ closeNetDetail();
950
+ $('nBadge').textContent = '0';
951
+ renderNetwork();
952
+ });
953
+
902
954
  // Close detail button
903
955
  $('netDetailClose').addEventListener('click', closeNetDetail);
904
956
 
@@ -1016,13 +1068,15 @@ function matchNetType(r, type) {
1016
1068
  const ct = (r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '').toLowerCase();
1017
1069
  const url = (r.url || '').toLowerCase();
1018
1070
  switch (type) {
1019
- case 'fetch': return true; // All XHR/fetch requests pass
1020
- case 'js': return ct.includes('javascript') || url.endsWith('.js') || url.endsWith('.bundle');
1021
- case 'css': return ct.includes('css') || url.endsWith('.css');
1022
- case 'img': return ct.includes('image') || /\.(png|jpg|jpeg|gif|svg|webp|ico)(\?|$)/i.test(url);
1023
- case 'media': return ct.includes('video') || ct.includes('audio') || /\.(mp4|mp3|wav|webm)(\?|$)/i.test(url);
1024
- case 'font': return ct.includes('font') || /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(url);
1025
- case 'doc': return ct.includes('html') || ct.includes('xml');
1071
+ case 'fetch': // Fetch/XHR show API calls (JSON, text, form data), exclude static assets
1072
+ return !ct.includes('image') && !ct.includes('font') && !ct.includes('video') && !ct.includes('audio')
1073
+ && !/\.(png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp4|mp3|css)(\?|$)/.test(url);
1074
+ case 'js': return ct.includes('javascript') || /\.(js|jsx|bundle)(\?|$)/.test(url);
1075
+ case 'css': return ct.includes('css') || /\.css(\?|$)/.test(url);
1076
+ case 'img': return ct.includes('image') || /\.(png|jpg|jpeg|gif|svg|webp|ico|avif|bmp)(\?|$)/.test(url);
1077
+ case 'media': return ct.includes('video') || ct.includes('audio') || /\.(mp4|mp3|wav|webm|ogg|m3u8)(\?|$)/.test(url);
1078
+ case 'font': return ct.includes('font') || /\.(woff2?|ttf|otf|eot)(\?|$)/.test(url);
1079
+ case 'doc': return ct.includes('html') || ct.includes('xml') || /\.(html?|xml)(\?|$)/.test(url);
1026
1080
  case 'ws': return url.startsWith('ws://') || url.startsWith('wss://');
1027
1081
  default: return true;
1028
1082
  }
@@ -1393,6 +1447,273 @@ function buildCurlCommand(r) {
1393
1447
  return cmd;
1394
1448
  }
1395
1449
 
1450
+ // ─────────────────────────────────────────────────────────────────────────────
1451
+ // GA4 EVENT INSPECTOR
1452
+ // ─────────────────────────────────────────────────────────────────────────────
1453
+ const ga4State = { events: [], selected: -1, searchFilter: '', sortDir: 'desc' };
1454
+
1455
+ function initGA4Panel() {
1456
+ const panel = $('panel-ga4');
1457
+ panel.innerHTML = `
1458
+ <div class="panel-toolbar">
1459
+ <span class="panel-label">GA4 Events</span>
1460
+ <span class="badge" id="ga4Badge">0</span>
1461
+ <input id="ga4Search" class="net-search-input" style="margin-left:12px" placeholder="Filter events..." />
1462
+ <div class="ml-auto">
1463
+ <button class="panel-clear-btn" id="ga4Clear" title="Clear GA4 events">Clear</button>
1464
+ </div>
1465
+ </div>
1466
+ <div class="ga4-layout">
1467
+ <div class="ga4-list-pane">
1468
+ <div class="ga4-list-header">
1469
+ <span class="ga4-hcell ga4-sort-btn" id="ga4SortBtn" style="width:90px;cursor:pointer" title="Click to toggle sort order">Time <span id="ga4SortIcon">\u25BC</span></span>
1470
+ <span class="ga4-hcell" style="flex:1">Event</span>
1471
+ </div>
1472
+ <div class="scroll-area" id="ga4List">
1473
+ <div class="empty-state" id="ga4Empty">
1474
+ <div class="icon" style="font-size:28px;opacity:.2">📊</div>
1475
+ <div class="label">No GA4 events yet</div>
1476
+ <div class="hint">Events from @react-native-firebase/analytics will appear here</div>
1477
+ </div>
1478
+ </div>
1479
+ </div>
1480
+ <div class="ga4-resize-handle" id="ga4ResizeHandle"></div>
1481
+ <div class="ga4-detail-pane" id="ga4DetailPane">
1482
+ <div class="ga4-detail-header">EVENT DETAIL</div>
1483
+ <div class="scroll-area ga4-detail-content" id="ga4Detail">
1484
+ <span style="color:var(--text-dim);padding:16px;display:block">Click an event to inspect</span>
1485
+ </div>
1486
+ </div>
1487
+ </div>
1488
+ <div class="ga4-summary" id="ga4Summary">
1489
+ <span class="ga4-summary-label">Total: 0</span>
1490
+ </div>`;
1491
+
1492
+ $('ga4Search').addEventListener('input', (e) => {
1493
+ ga4State.searchFilter = e.target.value.toLowerCase().trim();
1494
+ renderGA4List();
1495
+ renderGA4Summary(); // update active chip highlight
1496
+ });
1497
+
1498
+ $('ga4Clear').addEventListener('click', () => {
1499
+ ga4State.events = [];
1500
+ ga4State.selected = -1;
1501
+ $('ga4Badge').textContent = '0';
1502
+ renderGA4List();
1503
+ renderGA4Summary();
1504
+ });
1505
+
1506
+ $('ga4SortBtn').addEventListener('click', () => {
1507
+ ga4State.sortDir = ga4State.sortDir === 'desc' ? 'asc' : 'desc';
1508
+ $('ga4SortIcon').textContent = ga4State.sortDir === 'desc' ? '\u25BC' : '\u25B2';
1509
+ renderGA4List();
1510
+ });
1511
+
1512
+ // Resizable divider between list and detail
1513
+ const resizeHandle = $('ga4ResizeHandle');
1514
+ const detailPane = $('ga4DetailPane');
1515
+ resizeHandle.addEventListener('mousedown', (e) => {
1516
+ e.preventDefault();
1517
+ const startX = e.clientX;
1518
+ const startWidth = detailPane.offsetWidth;
1519
+ document.body.style.cursor = 'col-resize';
1520
+ document.body.style.userSelect = 'none';
1521
+ function onMove(ev) {
1522
+ const delta = startX - ev.clientX;
1523
+ detailPane.style.width = Math.max(200, Math.min(window.innerWidth * 0.8, startWidth + delta)) + 'px';
1524
+ }
1525
+ function onUp() {
1526
+ document.body.style.cursor = '';
1527
+ document.body.style.userSelect = '';
1528
+ document.removeEventListener('mousemove', onMove);
1529
+ document.removeEventListener('mouseup', onUp);
1530
+ }
1531
+ document.addEventListener('mousemove', onMove);
1532
+ document.addEventListener('mouseup', onUp);
1533
+ });
1534
+ }
1535
+
1536
+ function handleGA4Event(event) {
1537
+ ga4State.events.push({
1538
+ name: event.name || '?',
1539
+ params: event.params || {},
1540
+ tag: event.tag || 'GA4',
1541
+ source: event.source || '',
1542
+ ts: event.ts || Date.now(),
1543
+ index: ga4State.events.length,
1544
+ });
1545
+ $('ga4Badge').textContent = ga4State.events.length;
1546
+
1547
+ // Append to list (batched via rAF)
1548
+ if (!ga4State._raf) {
1549
+ ga4State._raf = requestAnimationFrame(() => {
1550
+ ga4State._raf = null;
1551
+ renderGA4List();
1552
+ renderGA4Summary();
1553
+ });
1554
+ }
1555
+ }
1556
+
1557
+ function renderGA4List() {
1558
+ const list = $('ga4List');
1559
+ const empty = $('ga4Empty');
1560
+ if (!list) return;
1561
+
1562
+ const { searchFilter, sortDir } = ga4State;
1563
+ let visible = ga4State.events.filter(e =>
1564
+ !searchFilter || e.name.toLowerCase().includes(searchFilter)
1565
+ );
1566
+
1567
+ // Sort: newest first (desc) or oldest first (asc)
1568
+ if (sortDir === 'desc') {
1569
+ visible = [...visible].reverse();
1570
+ }
1571
+
1572
+ empty.style.display = visible.length ? 'none' : 'flex';
1573
+ list.querySelectorAll('.ga4-row').forEach(e => e.remove());
1574
+
1575
+ // Cap at 500 rows
1576
+ const MAX = 500;
1577
+ const toRender = visible.length > MAX ? visible.slice(0, MAX) : visible;
1578
+
1579
+ const frag = document.createDocumentFragment();
1580
+ toRender.forEach(e => {
1581
+ const row = document.createElement('div');
1582
+ row.className = 'ga4-row' + (e.index === ga4State.selected ? ' selected' : '');
1583
+
1584
+ const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 });
1585
+
1586
+ row.innerHTML = `
1587
+ <span class="ga4-cell ga4-time">${time}</span>
1588
+ <span class="ga4-cell ga4-name">${esc(e.name)}</span>`;
1589
+
1590
+ row.addEventListener('click', () => {
1591
+ ga4State.selected = e.index;
1592
+ list.querySelectorAll('.ga4-row').forEach(r => r.classList.remove('selected'));
1593
+ row.classList.add('selected');
1594
+ renderGA4Detail(e);
1595
+ });
1596
+
1597
+ // Right-click to copy
1598
+ row.addEventListener('contextmenu', (ev) => {
1599
+ ev.preventDefault();
1600
+ showContextMenu(ev, [
1601
+ { label: 'Copy Event Name', action: () => navigator.clipboard.writeText(e.name) },
1602
+ { label: 'Copy as JSON', action: () => navigator.clipboard.writeText(JSON.stringify({ event: e.name, params: e.params }, null, 2)) },
1603
+ ]);
1604
+ });
1605
+
1606
+ frag.appendChild(row);
1607
+ });
1608
+ list.appendChild(frag);
1609
+ }
1610
+
1611
+ function renderGA4Detail(e) {
1612
+ const detail = $('ga4Detail');
1613
+ if (!detail) return;
1614
+
1615
+ const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 });
1616
+
1617
+ detail.innerHTML = '';
1618
+
1619
+ // Header info
1620
+ const header = document.createElement('div');
1621
+ header.className = 'ga4-detail-info';
1622
+ header.innerHTML = `
1623
+ <div class="ga4-detail-row"><span class="ga4-detail-key">Event Name</span><span class="ga4-detail-val" style="color:var(--accent);font-weight:600">${esc(e.name)}</span></div>
1624
+ <div class="ga4-detail-row"><span class="ga4-detail-key">Timestamp</span><span class="ga4-detail-val">${time}</span></div>
1625
+ `;
1626
+ detail.appendChild(header);
1627
+
1628
+ // Separator
1629
+ const sep = document.createElement('div');
1630
+ sep.className = 'ga4-detail-sep';
1631
+ detail.appendChild(sep);
1632
+
1633
+ // Parameters as key-value list with collapsible objects
1634
+ if (e.params && typeof e.params === 'object') {
1635
+ const keys = Object.keys(e.params).sort();
1636
+ keys.forEach(key => {
1637
+ const val = e.params[key];
1638
+ const row = document.createElement('div');
1639
+ row.className = 'ga4-param-row';
1640
+
1641
+ const keyEl = document.createElement('span');
1642
+ keyEl.className = 'ga4-param-key';
1643
+ keyEl.textContent = key;
1644
+ row.appendChild(keyEl);
1645
+
1646
+ if (val && typeof val === 'object') {
1647
+ // Collapsible object tree
1648
+ const treeWrap = document.createElement('span');
1649
+ treeWrap.className = 'ga4-param-val';
1650
+ treeWrap.appendChild(createTreeNode(null, val, true));
1651
+ row.appendChild(treeWrap);
1652
+ } else {
1653
+ const valEl = document.createElement('span');
1654
+ valEl.className = 'ga4-param-val';
1655
+ valEl.textContent = val === null ? 'null' : val === undefined ? 'undefined' : JSON.stringify(val);
1656
+ if (typeof val === 'string') valEl.style.color = 'var(--green)';
1657
+ else if (typeof val === 'number') valEl.style.color = 'var(--orange)';
1658
+ else if (typeof val === 'boolean') valEl.style.color = 'var(--accent2)';
1659
+ row.appendChild(valEl);
1660
+ }
1661
+
1662
+ detail.appendChild(row);
1663
+ });
1664
+ }
1665
+
1666
+ // Right-click on detail
1667
+ detail.addEventListener('contextmenu', (ev) => {
1668
+ ev.preventDefault();
1669
+ showContextMenu(ev, [
1670
+ { label: 'Copy All Parameters', action: () => navigator.clipboard.writeText(JSON.stringify(e.params, null, 2)) },
1671
+ { label: 'Copy Event JSON', action: () => navigator.clipboard.writeText(JSON.stringify({ event: e.name, params: e.params, timestamp: e.ts }, null, 2)) },
1672
+ ]);
1673
+ });
1674
+ }
1675
+
1676
+ function renderGA4Summary() {
1677
+ const summary = $('ga4Summary');
1678
+ if (!summary) return;
1679
+
1680
+ const counts = {};
1681
+ ga4State.events.forEach(e => {
1682
+ counts[e.name] = (counts[e.name] || 0) + 1;
1683
+ });
1684
+
1685
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
1686
+
1687
+ summary.innerHTML = '';
1688
+
1689
+ const totalLabel = document.createElement('span');
1690
+ totalLabel.className = 'ga4-summary-label';
1691
+ totalLabel.textContent = `Total: ${ga4State.events.length}`;
1692
+ summary.appendChild(totalLabel);
1693
+
1694
+ sorted.forEach(([name, count]) => {
1695
+ const chip = document.createElement('span');
1696
+ const isActive = ga4State.searchFilter === name.toLowerCase();
1697
+ chip.className = 'ga4-summary-chip' + (isActive ? ' active' : '');
1698
+ chip.innerHTML = `<b>${esc(name)}</b><span class="chip-count">${count}</span>`;
1699
+ chip.addEventListener('click', () => {
1700
+ const search = $('ga4Search');
1701
+ if (isActive) {
1702
+ // Clear filter
1703
+ ga4State.searchFilter = '';
1704
+ if (search) search.value = '';
1705
+ } else {
1706
+ // Set filter to this event name
1707
+ ga4State.searchFilter = name.toLowerCase();
1708
+ if (search) search.value = name;
1709
+ }
1710
+ renderGA4List();
1711
+ renderGA4Summary();
1712
+ });
1713
+ summary.appendChild(chip);
1714
+ });
1715
+ }
1716
+
1396
1717
  // ─────────────────────────────────────────────────────────────────────────────
1397
1718
  // REDUX PANEL
1398
1719
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1402,8 +1723,9 @@ function initReduxPanel() {
1402
1723
  <div class="panel-toolbar">
1403
1724
  <span class="panel-label">Redux</span>
1404
1725
  <span class="badge" id="rBadge">0</span>
1726
+ <input id="reduxSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter actions..." />
1405
1727
  <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
1406
- <input id="reduxSearch" class="net-search-input" placeholder="Filter actions..." />
1728
+ <button class="panel-clear-btn" id="reduxClear" title="Clear redux">Clear</button>
1407
1729
  <div class="time-travel-bar" style="border:none;padding:0;margin:0">
1408
1730
  <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected-1)">◀</button>
1409
1731
  <span class="tt-label" id="ttLabel">—/—</span>
@@ -1423,6 +1745,14 @@ function initReduxPanel() {
1423
1745
  state.redux.searchFilter = e.target.value.toLowerCase().trim();
1424
1746
  renderRedux();
1425
1747
  });
1748
+
1749
+ $('reduxClear').addEventListener('click', () => {
1750
+ state.redux.actions = [];
1751
+ state.redux.states = [];
1752
+ state.redux.selected = -1;
1753
+ $('rBadge').textContent = '0';
1754
+ renderRedux();
1755
+ });
1426
1756
  }
1427
1757
 
1428
1758
  window.reduxJumpTo = idx => {
@@ -2455,6 +2785,7 @@ function handleMemoryEvent(event) {
2455
2785
  // ─────────────────────────────────────────────────────────────────────────────
2456
2786
  initConsolePanel();
2457
2787
  initNetworkPanel();
2788
+ initGA4Panel();
2458
2789
  initPerformancePanel();
2459
2790
  initMemoryPanel();
2460
2791
  initReduxPanel();