reactoradar 1.2.3 → 1.4.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/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);
@@ -225,17 +233,30 @@ if (window.electronAPI) {
225
233
 
226
234
  window.electronAPI.on('clear-all-ui', clearAll);
227
235
 
236
+ window.electronAPI.on('app-version', (version) => {
237
+ state._appVersion = version;
238
+ const el = $('aboutVersion');
239
+ if (el) el.textContent = 'v' + version;
240
+ });
241
+
228
242
  window.electronAPI.on('update-available', ({ current, latest }) => {
229
- const banner = document.createElement('div');
230
- banner.className = 'update-banner';
231
- banner.innerHTML = `New version <b>v${latest}</b> available (current: v${current}).
232
- <a class="update-link" id="updateLink">Download update</a>
233
- <span class="update-dismiss" id="updateDismiss">&times;</span>`;
234
- document.getElementById('app').prepend(banner);
235
- $('updateLink')?.addEventListener('click', () => {
236
- window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger/releases');
237
- });
238
- $('updateDismiss')?.addEventListener('click', () => banner.remove());
243
+ // Show in settings only, not as a banner
244
+ state._updateAvailable = { current, latest };
245
+ const el = $('aboutVersion');
246
+ if (el) el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${latest} available</span>`;
247
+ // Add update button in settings if not already there
248
+ if (!$('updateBtn')) {
249
+ const aboutEl = document.querySelector('.settings-about');
250
+ if (aboutEl) {
251
+ const btn = document.createElement('div');
252
+ btn.style.cssText = 'margin-top:10px';
253
+ btn.innerHTML = '<button id="updateBtn" class="tb-btn primary" style="font-size:11px">Download v' + latest + '</button>';
254
+ aboutEl.appendChild(btn);
255
+ $('updateBtn')?.addEventListener('click', () => {
256
+ window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger/releases');
257
+ });
258
+ }
259
+ }
239
260
  });
240
261
 
241
262
  window.electronAPI.on('trigger-open-cdp', () => {
@@ -272,26 +293,40 @@ function updateDeviceBanner(service, connected) {
272
293
  // ─────────────────────────────────────────────────────────────────────────────
273
294
  // CONSOLE PANEL
274
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
+
275
308
  function initConsolePanel() {
276
309
  const panel = $('panel-console');
310
+ const levels = getStoredLogLevels();
311
+ state.console.levelFilters = levels;
312
+
277
313
  panel.innerHTML = `
278
314
  <div class="panel-toolbar">
279
315
  <span class="panel-label">Console</span>
280
316
  <span class="badge" id="cBadge">0</span>
281
- <div class="tab-row" style="margin-left:12px">
282
- <button class="tab active" onclick="setConsoleLevel('all',this)">All</button>
283
- <button class="tab" onclick="setConsoleLevel('log',this)">Log</button>
284
- <button class="tab" onclick="setConsoleLevel('info',this)">Info</button>
285
- <button class="tab" onclick="setConsoleLevel('warn',this)">Warn</button>
286
- <button class="tab" onclick="setConsoleLevel('error',this)">Error</button>
287
- </div>
288
- <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
289
- <input id="consoleSearch" class="net-search-input" placeholder="Filter logs..." />
290
- <label class="toggle-label" for="stackTraceToggle" title="Capture stack trace (caller file:line) disabled by default for performance">
291
- <span class="toggle-text" id="stackTraceText">Stack OFF</span>
292
- <input type="checkbox" id="stackTraceToggle" class="toggle-input" />
293
- <span class="toggle-slider"></span>
294
- </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>
295
330
  </div>
296
331
  </div>
297
332
  <div class="scroll-area" id="consoleList">
@@ -302,25 +337,60 @@ function initConsolePanel() {
302
337
  </div>
303
338
  </div>`;
304
339
 
340
+ // Search filter
305
341
  $('consoleSearch').addEventListener('input', (e) => {
306
342
  state.console.searchFilter = e.target.value.toLowerCase().trim();
307
343
  renderConsole();
308
344
  });
309
345
 
310
- $('stackTraceToggle').addEventListener('change', (e) => {
311
- const enabled = e.target.checked;
312
- state.console.stackTraceEnabled = enabled;
313
- $('stackTraceText').textContent = enabled ? 'Stack ON' : 'Stack OFF';
314
- // Tell the SDK to enable/disable stack capture
315
- 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();
316
378
  });
317
379
  }
318
- window.setConsoleLevel = (level, btn) => {
319
- state.console.levelFilter = level;
320
- document.querySelectorAll('#panel-console .tab').forEach(b => b.classList.remove('active'));
321
- btn.classList.add('active');
322
- renderConsole();
323
- };
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
+ }
324
394
 
325
395
  // Console is fed via IPC (network-event handled in IPC section above)
326
396
 
@@ -349,12 +419,12 @@ function flushConsoleBatch() {
349
419
  const empty = $('consoleEmpty');
350
420
  if (!list) return;
351
421
 
352
- const { levelFilter, searchFilter } = state.console;
422
+ const { levelFilters, searchFilter } = state.console;
353
423
  const frag = document.createDocumentFragment();
354
424
  let added = 0;
355
425
 
356
426
  batch.forEach(l => {
357
- if (levelFilter !== 'all' && l.level !== levelFilter) return;
427
+ if (levelFilters && !levelFilters[l.level]) return;
358
428
  if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return;
359
429
  frag.appendChild(buildLogRow(l));
360
430
  added++;
@@ -621,27 +691,23 @@ function buildLogRow(l) {
621
691
  lvlSpan.textContent = l.level;
622
692
  div.appendChild(lvlSpan);
623
693
 
624
- // 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
625
701
  const bodyWrap = document.createElement('div');
626
702
  bodyWrap.className = 'log-body-wrap';
627
703
 
628
- // Single-line preview with caller at end
704
+ // Single-line preview: message text + caller
629
705
  const preview = document.createElement('div');
630
706
  preview.className = 'log-preview';
631
707
  const msgText = (l.message || '').replace(/\n/g, ' ').slice(0, 200);
632
708
  const previewText = document.createElement('span');
633
709
  previewText.textContent = msgText + ((l.message || '').length > 200 ? '...' : '');
634
710
  preview.appendChild(previewText);
635
- if (l.caller) {
636
- // Extract short filename:line from caller like "at Component (file.js:42:10)"
637
- const callerShort = extractCallerShort(l.caller);
638
- if (callerShort) {
639
- const callerTag = document.createElement('span');
640
- callerTag.className = 'log-caller-inline';
641
- callerTag.textContent = callerShort;
642
- preview.appendChild(callerTag);
643
- }
644
- }
645
711
  bodyWrap.appendChild(preview);
646
712
 
647
713
  // Full content (hidden by default)
@@ -649,20 +715,8 @@ function buildLogRow(l) {
649
715
  full.className = 'log-full';
650
716
  full.style.display = 'none';
651
717
  full.appendChild(buildLogBody(l));
652
- if (l.caller) {
653
- const callerSpan = document.createElement('span');
654
- callerSpan.className = 'log-caller';
655
- callerSpan.textContent = l.caller;
656
- full.appendChild(callerSpan);
657
- }
658
718
  bodyWrap.appendChild(full);
659
719
 
660
- // Expand/collapse arrow
661
- const arrow = document.createElement('span');
662
- arrow.className = 'log-arrow';
663
- arrow.textContent = '\u25B6';
664
- bodyWrap.prepend(arrow);
665
-
666
720
  let expanded = false;
667
721
  // Only toggle on click, NOT on text selection drag
668
722
  let _mouseDownPos = null;
@@ -761,9 +815,9 @@ function renderConsole() {
761
815
  const empty = $('consoleEmpty');
762
816
  if (!list) return;
763
817
 
764
- const { levelFilter, searchFilter } = state.console;
818
+ const { levelFilters, searchFilter } = state.console;
765
819
  const visible = state.console.logs.filter(l => {
766
- if (levelFilter !== 'all' && l.level !== levelFilter) return false;
820
+ if (levelFilters && !levelFilters[l.level]) return false;
767
821
  if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return false;
768
822
  return true;
769
823
  });
@@ -792,13 +846,13 @@ function renderConsole() {
792
846
  // NETWORK PANEL (Chrome DevTools-style)
793
847
  // ─────────────────────────────────────────────────────────────────────────────
794
848
  const NET_COLS = [
795
- { key: 'name', label: 'Name', width: 260, min: 100 },
849
+ { key: 'name', label: 'Name', width: 380, min: 150 },
796
850
  { key: 'status', label: 'Status', width: 60, min: 40 },
797
851
  { key: 'type', label: 'Type', width: 70, min: 40 },
798
- { key: 'initiator', label: 'Initiator', width: 90, min: 50 },
799
- { key: 'size', label: 'Size', width: 70, min: 40 },
800
- { key: 'time', label: 'Time', width: 70, min: 40 },
801
- { 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 },
802
856
  ];
803
857
 
804
858
  function initNetworkPanel() {
@@ -808,6 +862,7 @@ function initNetworkPanel() {
808
862
  <span class="panel-label">Network</span>
809
863
  <span class="badge" id="nBadge">0</span>
810
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>
811
866
  <label class="toggle-label" for="netToggle">
812
867
  <span class="toggle-text" id="netToggleText">Capture ON</span>
813
868
  <input type="checkbox" id="netToggle" class="toggle-input" checked />
@@ -886,6 +941,16 @@ function initNetworkPanel() {
886
941
  window.electronAPI?.setNetworkThrottle(state.network.throttle);
887
942
  });
888
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
+
889
954
  // Close detail button
890
955
  $('netDetailClose').addEventListener('click', closeNetDetail);
891
956
 
@@ -1003,13 +1068,15 @@ function matchNetType(r, type) {
1003
1068
  const ct = (r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '').toLowerCase();
1004
1069
  const url = (r.url || '').toLowerCase();
1005
1070
  switch (type) {
1006
- case 'fetch': return true; // All XHR/fetch requests pass
1007
- case 'js': return ct.includes('javascript') || url.endsWith('.js') || url.endsWith('.bundle');
1008
- case 'css': return ct.includes('css') || url.endsWith('.css');
1009
- case 'img': return ct.includes('image') || /\.(png|jpg|jpeg|gif|svg|webp|ico)(\?|$)/i.test(url);
1010
- case 'media': return ct.includes('video') || ct.includes('audio') || /\.(mp4|mp3|wav|webm)(\?|$)/i.test(url);
1011
- case 'font': return ct.includes('font') || /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(url);
1012
- 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);
1013
1080
  case 'ws': return url.startsWith('ws://') || url.startsWith('wss://');
1014
1081
  default: return true;
1015
1082
  }
@@ -1380,6 +1447,273 @@ function buildCurlCommand(r) {
1380
1447
  return cmd;
1381
1448
  }
1382
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
+
1383
1717
  // ─────────────────────────────────────────────────────────────────────────────
1384
1718
  // REDUX PANEL
1385
1719
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1389,8 +1723,9 @@ function initReduxPanel() {
1389
1723
  <div class="panel-toolbar">
1390
1724
  <span class="panel-label">Redux</span>
1391
1725
  <span class="badge" id="rBadge">0</span>
1726
+ <input id="reduxSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter actions..." />
1392
1727
  <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
1393
- <input id="reduxSearch" class="net-search-input" placeholder="Filter actions..." />
1728
+ <button class="panel-clear-btn" id="reduxClear" title="Clear redux">Clear</button>
1394
1729
  <div class="time-travel-bar" style="border:none;padding:0;margin:0">
1395
1730
  <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected-1)">◀</button>
1396
1731
  <span class="tt-label" id="ttLabel">—/—</span>
@@ -1410,6 +1745,14 @@ function initReduxPanel() {
1410
1745
  state.redux.searchFilter = e.target.value.toLowerCase().trim();
1411
1746
  renderRedux();
1412
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
+ });
1413
1756
  }
1414
1757
 
1415
1758
  window.reduxJumpTo = idx => {
@@ -1880,7 +2223,7 @@ function initSettingsPanel() {
1880
2223
  <div class="settings-section-title">About</div>
1881
2224
  <div class="settings-about">
1882
2225
  <div class="about-name" id="aboutAppName">${getStoredAppName()}</div>
1883
- <div class="about-version">v1.2.0</div>
2226
+ <div class="about-version" id="aboutVersion">v${state._appVersion || '...'}</div>
1884
2227
  <div class="about-desc">A standalone macOS debugger for React Native apps.<br/>Supports Hermes, New Architecture, and React Native 0.74+.</div>
1885
2228
  <div class="about-links" style="display:flex;gap:16px;justify-content:center">
1886
2229
  <span class="about-link" id="linkGithub">GitHub</span>
@@ -2442,6 +2785,7 @@ function handleMemoryEvent(event) {
2442
2785
  // ─────────────────────────────────────────────────────────────────────────────
2443
2786
  initConsolePanel();
2444
2787
  initNetworkPanel();
2788
+ initGA4Panel();
2445
2789
  initPerformancePanel();
2446
2790
  initMemoryPanel();
2447
2791
  initReduxPanel();