reactoradar 1.5.9 → 1.6.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
@@ -94,15 +94,46 @@ function highlight(html, term) {
94
94
  }
95
95
 
96
96
  // ─── Navigation ───────────────────────────────────────────────────────────────
97
+ function _getPanelOrder() {
98
+ const order = getTabOrder();
99
+ // Always include settings at the end
100
+ if (!order.includes('settings')) order.push('settings');
101
+ return order;
102
+ }
103
+
104
+ function switchPanel(panel) {
105
+ if (!$(`panel-${panel}`)) return;
106
+ document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
107
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
108
+ const btn = document.querySelector(`.nav-btn[data-panel="${panel}"]`);
109
+ if (btn) btn.classList.add('active');
110
+ $(`panel-${panel}`).classList.add('active');
111
+ state.activePanel = panel;
112
+ }
113
+
97
114
  document.querySelectorAll('.nav-btn').forEach(btn => {
98
- btn.addEventListener('click', () => {
99
- const panel = btn.dataset.panel;
100
- document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
101
- document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
102
- btn.classList.add('active');
103
- $(`panel-${panel}`).classList.add('active');
104
- state.activePanel = panel;
105
- });
115
+ btn.addEventListener('click', () => switchPanel(btn.dataset.panel));
116
+ });
117
+
118
+ // Keyboard shortcuts: Cmd+1–9 for panel switching, Cmd+K clear
119
+ document.addEventListener('keydown', (e) => {
120
+ if (!(e.metaKey || e.ctrlKey)) return;
121
+ const num = parseInt(e.key);
122
+ const panelOrder = _getPanelOrder();
123
+ if (num >= 1 && num <= panelOrder.length) {
124
+ e.preventDefault();
125
+ const vis = getTabVisibility();
126
+ const target = panelOrder[num - 1];
127
+ if (vis[target] !== false) switchPanel(target);
128
+ }
129
+ if (e.key === 'k') {
130
+ e.preventDefault();
131
+ clearActiveTab();
132
+ }
133
+ if (e.key === 's') {
134
+ e.preventDefault();
135
+ takeScreenshot();
136
+ }
106
137
  });
107
138
 
108
139
  // Global filter removed — each panel has its own search input
@@ -193,11 +224,25 @@ function clearAll() {
193
224
  }
194
225
 
195
226
  // ─── CDP Button ───────────────────────────────────────────────────────────────
196
- $('btnCDP').addEventListener('click', () => {
227
+ $('btnCDP')?.addEventListener('click', () => {
197
228
  // Tell main process to open the CDP DevTools window with the best available target
198
229
  window.electronAPI?.openCDPTarget(null); // null = use latest known target
199
230
  });
200
231
 
232
+ // ─── Screenshot Button ────────────────────────────────────────────────────────
233
+ $('btnScreenshot')?.addEventListener('click', takeScreenshot);
234
+
235
+ function takeScreenshot() {
236
+ const btn = $('btnScreenshot');
237
+ if (!btn) return;
238
+ const origText = btn.innerHTML;
239
+ btn.innerHTML = '<span style="opacity:0.6">Saving...</span>';
240
+ // Use Electron's native capturePage — always works, no DOM rendering issues
241
+ window.electronAPI?.captureScreenshot();
242
+ btn.innerHTML = '<span style="color:var(--green)">Saved!</span>';
243
+ setTimeout(() => { btn.innerHTML = origText; }, 2000);
244
+ }
245
+
201
246
  // ─────────────────────────────────────────────────────────────────────────────
202
247
  // IPC from Main
203
248
  // ─────────────────────────────────────────────────────────────────────────────
@@ -273,9 +318,13 @@ if (window.electronAPI) {
273
318
  document.querySelectorAll('#aboutVersion').forEach(el => el.textContent = 'v' + version);
274
319
  });
275
320
 
276
- window.electronAPI.on('update-available', ({ current, latest }) => {
277
- // Show in settings only, not as a banner
278
- state._updateAvailable = { current, latest };
321
+ window.electronAPI.on('update-available', ({ current, latest, autoUpdate }) => {
322
+ state._updateAvailable = { current, latest, autoUpdate };
323
+ _applyUpdateBanner();
324
+ });
325
+
326
+ window.electronAPI.on('update-downloaded', ({ version }) => {
327
+ state._updateDownloaded = version;
279
328
  _applyUpdateBanner();
280
329
  });
281
330
 
@@ -297,23 +346,47 @@ if (window.electronAPI) {
297
346
  function _applyUpdateBanner() {
298
347
  const info = state._updateAvailable;
299
348
  if (!info) return;
300
- const { current, latest } = info;
349
+ const { current, latest, autoUpdate } = info;
350
+ const downloaded = state._updateDownloaded;
351
+
301
352
  const el = $('aboutVersion');
302
- if (el && !el.dataset.updateApplied) {
303
- el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${latest} available</span>`;
304
- el.dataset.updateApplied = '1';
353
+ if (el) {
354
+ if (downloaded) {
355
+ el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${downloaded} ready to install</span>`;
356
+ } else {
357
+ el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${latest} available</span>`;
358
+ }
305
359
  }
360
+
361
+ // Remove old button if state changed
362
+ const oldBtn = $('updateBtn');
363
+ if (oldBtn && downloaded && !oldBtn.dataset.isRestart) oldBtn.parentElement?.remove();
364
+
306
365
  // Add update button in settings if not already there
307
366
  if (!$('updateBtn')) {
308
367
  const aboutEl = document.querySelector('.settings-about');
309
368
  if (aboutEl) {
310
369
  const btn = document.createElement('div');
311
370
  btn.style.cssText = 'margin-top:10px';
312
- btn.innerHTML = '<button id="updateBtn" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Download v' + latest + '</button>';
313
- aboutEl.appendChild(btn);
314
- $('updateBtn')?.addEventListener('click', () => {
315
- window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger/releases');
316
- });
371
+ if (downloaded) {
372
+ // Update is downloaded — show "Restart & Update"
373
+ btn.innerHTML = '<button id="updateBtn" data-is-restart="1" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Restart & Update to v' + downloaded + '</button>';
374
+ aboutEl.appendChild(btn);
375
+ $('updateBtn')?.addEventListener('click', () => {
376
+ window.electronAPI?.installUpdate();
377
+ });
378
+ } else if (autoUpdate) {
379
+ // Auto-update in progress — show downloading status
380
+ btn.innerHTML = '<button id="updateBtn" class="tb-btn" style="font-size:11px;padding:6px 16px;opacity:0.7" disabled>Downloading v' + latest + '...</button>';
381
+ aboutEl.appendChild(btn);
382
+ } else {
383
+ // npx/manual — show download link
384
+ btn.innerHTML = '<button id="updateBtn" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Download v' + latest + '</button>';
385
+ aboutEl.appendChild(btn);
386
+ $('updateBtn')?.addEventListener('click', () => {
387
+ window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger/releases');
388
+ });
389
+ }
317
390
  }
318
391
  }
319
392
  }
@@ -362,6 +435,7 @@ function initConsolePanel() {
362
435
  <span class="badge" id="cBadge">0</span>
363
436
  <input id="consoleSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter logs..." />
364
437
  <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
438
+ <button class="panel-clear-btn" id="consoleExport" title="Export logs as JSON">Export</button>
365
439
  <button class="panel-clear-btn" id="consoleClear" title="Clear console">Clear</button>
366
440
  <div class="console-level-dropdown" id="consoleLevelDropdown">
367
441
  <button class="console-level-btn" id="consoleLevelBtn">Levels ▾</button>
@@ -426,9 +500,19 @@ function initConsolePanel() {
426
500
 
427
501
  updateLevelBtnText();
428
502
 
503
+ $('consoleExport')?.addEventListener('click', () => {
504
+ const data = JSON.stringify(state.console.logs, null, 2);
505
+ const blob = new Blob([data], { type: 'application/json' });
506
+ const url = URL.createObjectURL(blob);
507
+ const a = document.createElement('a');
508
+ a.href = url; a.download = `reactoradar-console-${Date.now()}.json`; a.click();
509
+ URL.revokeObjectURL(url);
510
+ });
511
+
429
512
  $('consoleClear').addEventListener('click', () => {
430
513
  state.console.logs = [];
431
514
  _consolePending = [];
515
+ _lastLogMsg = ''; _lastLogRow = null; _lastLogCount = 1;
432
516
  $('cBadge').textContent = '0';
433
517
  renderConsole();
434
518
  });
@@ -497,13 +581,85 @@ function updateLevelBtnText() {
497
581
 
498
582
  // Console is fed via IPC (network-event handled in IPC section above)
499
583
 
584
+ // ─── Toast Notifications ─────────────────────────────────────────────────────
585
+ let _toastContainer = null;
586
+ const _activeToasts = {};
587
+
588
+ function getToastsEnabled() {
589
+ try { return localStorage.getItem('rn-debug-toasts') !== 'false'; } catch { return true; }
590
+ }
591
+ function setToastsEnabled(v) {
592
+ try { localStorage.setItem('rn-debug-toasts', v ? 'true' : 'false'); } catch {}
593
+ }
594
+
595
+ function showToast(message, type, targetPanel) {
596
+ if (!getToastsEnabled()) return;
597
+ if (!_toastContainer) {
598
+ _toastContainer = document.createElement('div');
599
+ _toastContainer.id = 'toastContainer';
600
+ _toastContainer.className = 'toast-container';
601
+ document.body.appendChild(_toastContainer);
602
+ }
603
+ // Don't show toast if user is already on the target panel
604
+ if (targetPanel && state.activePanel === targetPanel) return;
605
+
606
+ // Deduplicate: if same message already showing, increment count
607
+ const key = `${type}:${message}`;
608
+ if (_activeToasts[key] && _activeToasts[key].el.parentNode) {
609
+ const existing = _activeToasts[key];
610
+ existing.count++;
611
+ const msgEl = existing.el.querySelector('.toast-msg');
612
+ if (msgEl) msgEl.textContent = `${message} (${existing.count})`;
613
+ // Reset auto-remove timer
614
+ clearTimeout(existing.timer);
615
+ existing.timer = setTimeout(() => {
616
+ if (existing.el.parentNode) existing.el.remove();
617
+ delete _activeToasts[key];
618
+ }, 5000);
619
+ return;
620
+ }
621
+
622
+ const toast = document.createElement('div');
623
+ toast.className = `toast toast-${type || 'info'}`;
624
+ toast.innerHTML = `<span class="toast-msg">${esc(message)}</span>`;
625
+ if (targetPanel) {
626
+ const btn = document.createElement('span');
627
+ btn.className = 'toast-action';
628
+ btn.textContent = 'View';
629
+ btn.addEventListener('click', () => { switchPanel(targetPanel); toast.remove(); delete _activeToasts[key]; });
630
+ toast.appendChild(btn);
631
+ }
632
+ const close = document.createElement('span');
633
+ close.className = 'toast-close';
634
+ close.textContent = '✕';
635
+ close.addEventListener('click', () => { toast.remove(); delete _activeToasts[key]; });
636
+ toast.appendChild(close);
637
+
638
+ _toastContainer.appendChild(toast);
639
+ const timer = setTimeout(() => {
640
+ if (toast.parentNode) toast.remove();
641
+ delete _activeToasts[key];
642
+ }, 5000);
643
+ _activeToasts[key] = { el: toast, count: 1, timer };
644
+ // Keep max 3 toasts
645
+ const toasts = _toastContainer.querySelectorAll('.toast');
646
+ if (toasts.length > 3) { toasts[0].remove(); }
647
+ }
648
+
500
649
  // ─── Batched console append (fixes re-render performance) ────────────────────
501
650
  let _consolePending = [];
502
651
  let _consoleRAF = null;
503
652
 
653
+ let _lastLogMsg = '';
654
+ let _lastLogRow = null;
655
+ let _lastLogCount = 1;
656
+
657
+ const MAX_CONSOLE_LOGS = 5000;
658
+
504
659
  function addConsoleLog(event) {
505
660
  state.console.logs.push(event);
506
661
  _consolePending.push(event);
662
+
507
663
  // Batch DOM updates via rAF — only one paint per frame
508
664
  if (!_consoleRAF) {
509
665
  _consoleRAF = requestAnimationFrame(flushConsoleBatch);
@@ -532,7 +688,26 @@ function flushConsoleBatch() {
532
688
  if (!state.console.showRedux) return;
533
689
  } else if (levelFilters && !levelFilters[l.level]) return;
534
690
  if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return;
535
- frag.appendChild(buildLogRow(l));
691
+
692
+ // Group consecutive identical messages
693
+ const msgKey = `${l.level}:${l.message || ''}`;
694
+ if (msgKey === _lastLogMsg && _lastLogRow && _lastLogRow.parentNode) {
695
+ _lastLogCount++;
696
+ let badge = _lastLogRow.querySelector('.log-group-badge');
697
+ if (!badge) {
698
+ badge = document.createElement('span');
699
+ badge.className = 'log-group-badge';
700
+ _lastLogRow.insertBefore(badge, _lastLogRow.firstChild);
701
+ }
702
+ badge.textContent = _lastLogCount;
703
+ return; // Don't add a new row
704
+ }
705
+
706
+ _lastLogMsg = msgKey;
707
+ _lastLogCount = 1;
708
+ const row = buildLogRow(l);
709
+ _lastLogRow = row;
710
+ frag.appendChild(row);
536
711
  added++;
537
712
  });
538
713
 
@@ -542,9 +717,9 @@ function flushConsoleBatch() {
542
717
  // Auto-scroll only if user is already near the bottom (within 150px)
543
718
  const wasAtBottom = (list.scrollHeight - list.scrollTop - list.clientHeight) < 150;
544
719
  list.appendChild(frag);
545
- // Keep DOM size manageable — remove oldest rows if over limit
720
+ // Keep DOM size manageable — remove oldest rows
546
721
  const rows = list.querySelectorAll('.log-row');
547
- const MAX_DOM_ROWS = 5000;
722
+ const MAX_DOM_ROWS = 2000;
548
723
  if (rows.length > MAX_DOM_ROWS) {
549
724
  const toRemove = rows.length - MAX_DOM_ROWS;
550
725
  for (let i = 0; i < toRemove; i++) rows[i].remove();
@@ -911,6 +1086,12 @@ function showContextMenu(e, items) {
911
1086
  const menu = document.createElement('div');
912
1087
  menu.className = 'ctx-menu';
913
1088
  items.forEach(({ label, action }) => {
1089
+ if (label === '—' || !action) {
1090
+ const sep = document.createElement('div');
1091
+ sep.className = 'ctx-sep';
1092
+ menu.appendChild(sep);
1093
+ return;
1094
+ }
914
1095
  const item = document.createElement('div');
915
1096
  item.className = 'ctx-item';
916
1097
  item.textContent = label;
@@ -943,17 +1124,20 @@ function renderConsole() {
943
1124
  });
944
1125
 
945
1126
  list.querySelectorAll('.log-row').forEach(e => e.remove());
946
- if (visible.length > 0) {
1127
+ if (!empty) { /* guard */ }
1128
+ else if (visible.length > 0) {
947
1129
  empty.style.display = 'none';
948
1130
  } else if (state.console.logs.length > 0) {
949
- // Logs exist but are all filtered out
950
- empty.querySelector('.label').textContent = 'No matching logs';
951
- empty.querySelector('.hint').textContent = 'Adjust level filters or clear search to see logs';
1131
+ const lbl = empty.querySelector('.label');
1132
+ const hint = empty.querySelector('.hint');
1133
+ if (lbl) lbl.textContent = 'No matching logs';
1134
+ if (hint) hint.textContent = 'Adjust level filters or clear search to see logs';
952
1135
  empty.style.display = 'flex';
953
1136
  } else {
954
- // No logs at all
955
- empty.querySelector('.label').textContent = 'No logs yet';
956
- empty.querySelector('.hint').textContent = 'Logs will appear here automatically';
1137
+ const lbl = empty.querySelector('.label');
1138
+ const hint = empty.querySelector('.hint');
1139
+ if (lbl) lbl.textContent = 'No logs yet';
1140
+ if (hint) hint.textContent = 'Logs will appear here automatically';
957
1141
  empty.style.display = 'flex';
958
1142
  }
959
1143
 
@@ -994,6 +1178,7 @@ function initNetworkPanel() {
994
1178
  <span class="panel-label">Network</span>
995
1179
  <span class="badge" id="nBadge">0</span>
996
1180
  <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
1181
+ <button class="panel-clear-btn" id="networkExport" title="Export as HAR">Export HAR</button>
997
1182
  <button class="panel-clear-btn" id="networkClear" title="Clear network">Clear</button>
998
1183
  <label class="toggle-label" for="netToggle">
999
1184
  <span class="toggle-text" id="netToggleText">Capture ON</span>
@@ -1015,6 +1200,16 @@ function initNetworkPanel() {
1015
1200
  <button class="net-type-btn" data-type="doc">Doc</button>
1016
1201
  <button class="net-type-btn" data-type="ws">WS</button>
1017
1202
  </div>
1203
+ <div class="net-status-filters" id="netStatusFilters">
1204
+ <button class="net-status-btn active" data-status="all">All</button>
1205
+ <button class="net-status-btn" data-status="2xx">2xx</button>
1206
+ <button class="net-status-btn" data-status="errors">Errors</button>
1207
+ <button class="net-status-btn net-slow-btn" data-status="slow">Slow (>1s)</button>
1208
+ </div>
1209
+ <div class="net-hidden-wrap" style="position:relative;margin-left:4px">
1210
+ <button class="net-status-btn net-hidden-btn" id="netHiddenBtn" style="display:none" title="Manage hidden URLs">Hidden</button>
1211
+ <div class="net-hidden-dropdown" id="netHiddenDropdown" style="display:none"></div>
1212
+ </div>
1018
1213
  <div class="net-throttle" id="netThrottle">
1019
1214
  <select id="netThrottleSelect" class="net-throttle-select">
1020
1215
  <option value="none">No throttling</option>
@@ -1042,6 +1237,17 @@ function initNetworkPanel() {
1042
1237
  </div>
1043
1238
  <div class="detail-content" id="netDetailContent"></div>
1044
1239
  </div>
1240
+ </div>
1241
+ <div class="net-stats-bar" id="netStatsBar">
1242
+ <span id="netStatsTotal">0 requests</span>
1243
+ <span class="net-stats-sep">|</span>
1244
+ <span id="netStatsAvg">Avg: —</span>
1245
+ <span class="net-stats-sep">|</span>
1246
+ <span id="netStatsSlowest">Slowest: —</span>
1247
+ <span class="net-stats-sep">|</span>
1248
+ <span id="netStatsErrors">Errors: 0</span>
1249
+ <span class="net-stats-sep">|</span>
1250
+ <span id="netStatsSlow">Slow (>1s): 0</span>
1045
1251
  </div>`;
1046
1252
 
1047
1253
  $('netToggle').addEventListener('change', (e) => {
@@ -1066,6 +1272,69 @@ function initNetworkPanel() {
1066
1272
  renderNetwork();
1067
1273
  });
1068
1274
 
1275
+ // Status filter buttons (All / 2xx / Errors / Slow)
1276
+ $('netStatusFilters').addEventListener('click', (e) => {
1277
+ const btn = e.target.closest('.net-status-btn');
1278
+ if (!btn) return;
1279
+ $('netStatusFilters').querySelectorAll('.net-status-btn').forEach(b => b.classList.remove('active'));
1280
+ btn.classList.add('active');
1281
+ state.network.statusFilter = btn.dataset.status;
1282
+ renderNetwork();
1283
+ });
1284
+
1285
+ // Hidden URLs button
1286
+ $('netHiddenBtn')?.addEventListener('click', () => {
1287
+ const dd = $('netHiddenDropdown');
1288
+ if (!dd) return;
1289
+ const isOpen = dd.style.display !== 'none';
1290
+ if (isOpen) { dd.style.display = 'none'; return; }
1291
+ // Build dropdown with hidden URL list
1292
+ const hidden = getHiddenURLs();
1293
+ dd.innerHTML = '';
1294
+ if (!hidden.length) { dd.style.display = 'none'; return; }
1295
+ const title = document.createElement('div');
1296
+ title.className = 'net-hidden-title';
1297
+ title.innerHTML = `<span>Hidden URLs (${hidden.length})</span><button class="net-hidden-clear" id="netHiddenClearAll">Clear All</button>`;
1298
+ dd.appendChild(title);
1299
+ hidden.forEach(pattern => {
1300
+ const row = document.createElement('div');
1301
+ row.className = 'net-hidden-row';
1302
+ const label = document.createElement('span');
1303
+ label.className = 'net-hidden-url';
1304
+ label.textContent = pattern;
1305
+ label.title = pattern;
1306
+ row.appendChild(label);
1307
+ const btn = document.createElement('button');
1308
+ btn.className = 'net-hidden-unhide';
1309
+ btn.textContent = 'Unhide';
1310
+ btn.addEventListener('click', () => {
1311
+ removeHiddenURL(pattern);
1312
+ row.remove();
1313
+ renderNetwork();
1314
+ if (!getHiddenURLs().length) dd.style.display = 'none';
1315
+ });
1316
+ row.appendChild(btn);
1317
+ dd.appendChild(row);
1318
+ });
1319
+ dd.style.display = 'block';
1320
+ // Clear all handler
1321
+ dd.querySelector('#netHiddenClearAll')?.addEventListener('click', () => {
1322
+ setHiddenURLs([]);
1323
+ _updateHiddenBadge();
1324
+ dd.style.display = 'none';
1325
+ renderNetwork();
1326
+ });
1327
+ });
1328
+ // Close dropdown when clicking outside
1329
+ document.addEventListener('click', (e) => {
1330
+ const dd = $('netHiddenDropdown');
1331
+ if (dd && dd.style.display !== 'none' && !e.target.closest('.net-hidden-wrap')) {
1332
+ dd.style.display = 'none';
1333
+ }
1334
+ });
1335
+ // Initialize hidden badge
1336
+ _updateHiddenBadge();
1337
+
1069
1338
  // Throttle select
1070
1339
  $('netThrottleSelect').addEventListener('change', (e) => {
1071
1340
  state.network.throttle = e.target.value;
@@ -1073,6 +1342,37 @@ function initNetworkPanel() {
1073
1342
  window.electronAPI?.setNetworkThrottle(state.network.throttle);
1074
1343
  });
1075
1344
 
1345
+ // Export network as HAR
1346
+ $('networkExport')?.addEventListener('click', () => {
1347
+ const entries = state.network.order.map(id => {
1348
+ const r = state.network.requests[id];
1349
+ if (!r) return null;
1350
+ return {
1351
+ startedDateTime: new Date(r.ts || Date.now()).toISOString(),
1352
+ time: r.duration || 0,
1353
+ request: {
1354
+ method: r.method || 'GET',
1355
+ url: r.url || '',
1356
+ headers: Object.entries(r.requestHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
1357
+ postData: r.requestBody ? { mimeType: 'application/json', text: typeof r.requestBody === 'object' ? JSON.stringify(r.requestBody) : String(r.requestBody) } : undefined,
1358
+ },
1359
+ response: {
1360
+ status: r.status || 0,
1361
+ statusText: r.statusText || '',
1362
+ headers: Object.entries(r.responseHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
1363
+ content: { size: -1, mimeType: 'application/json', text: r.responseBody ? (typeof r.responseBody === 'object' ? JSON.stringify(r.responseBody) : String(r.responseBody)) : '' },
1364
+ },
1365
+ timings: { send: 0, wait: r.duration || 0, receive: 0 },
1366
+ };
1367
+ }).filter(Boolean);
1368
+ const har = { log: { version: '1.2', creator: { name: 'ReactoRadar', version: '1.6.0' }, entries } };
1369
+ const blob = new Blob([JSON.stringify(har, null, 2)], { type: 'application/json' });
1370
+ const url = URL.createObjectURL(blob);
1371
+ const a = document.createElement('a');
1372
+ a.href = url; a.download = `reactoradar-network-${Date.now()}.har`; a.click();
1373
+ URL.revokeObjectURL(url);
1374
+ });
1375
+
1076
1376
  // Clear network
1077
1377
  $('networkClear').addEventListener('click', () => {
1078
1378
  state.network.requests = {};
@@ -1225,9 +1525,25 @@ function handleNetworkEvent(event) {
1225
1525
  if (phase === 'request') {
1226
1526
  state.network.requests[id] = { ...event, _tab: 'headers' };
1227
1527
  if (!state.network.order.includes(id)) state.network.order.push(id);
1528
+ // Cap network history to prevent memory leak
1529
+ const MAX_NET_HISTORY = 1000;
1530
+ if (state.network.order.length > MAX_NET_HISTORY) {
1531
+ const trimIds = state.network.order.splice(0, state.network.order.length - MAX_NET_HISTORY);
1532
+ trimIds.forEach(tid => delete state.network.requests[tid]);
1533
+ }
1228
1534
  $('nBadge').textContent = state.network.order.length;
1229
1535
  } else {
1230
1536
  Object.assign(state.network.requests[id] || (state.network.requests[id] = {}), event);
1537
+ // Toast for errors and slow APIs
1538
+ const r = state.network.requests[id];
1539
+ if (r && (phase === 'response' || phase === 'error')) {
1540
+ const name = r.url?.split('/').pop()?.split('?')[0] || r.url || '?';
1541
+ if (r.phase === 'error' || (r.status && r.status >= 400)) {
1542
+ showToast(`API Error: ${r.status || 'ERR'} ${name}`, 'error', 'network');
1543
+ } else if ((r.duration || 0) >= 3000) {
1544
+ showToast(`Slow API: ${(r.duration/1000).toFixed(1)}s — ${name}`, 'warn', 'network');
1545
+ }
1546
+ }
1231
1547
  }
1232
1548
  if (!_netRAF) {
1233
1549
  _netRAF = requestAnimationFrame(() => {
@@ -1283,8 +1599,10 @@ function renderNetwork() {
1283
1599
  if (!r) return false;
1284
1600
  if (statusFilter === '2xx' && !(r.status >= 200 && r.status < 300)) return false;
1285
1601
  if (statusFilter === 'errors' && !(r.phase === 'error' || r.status >= 400)) return false;
1602
+ if (statusFilter === 'slow' && !((r.duration || 0) >= 1000)) return false;
1286
1603
  if (searchFilter && !r.url?.toLowerCase().includes(searchFilter)) return false;
1287
1604
  if (typeFilter !== 'all' && !matchNetType(r, typeFilter)) return false;
1605
+ if (isURLHidden(r.url || '')) return false;
1288
1606
  return true;
1289
1607
  });
1290
1608
 
@@ -1319,6 +1637,32 @@ function renderNetwork() {
1319
1637
  frag.appendChild(buildNetRow(r, wfMin, wfRange));
1320
1638
  });
1321
1639
  rows.appendChild(frag);
1640
+ _updateNetStats();
1641
+ }
1642
+
1643
+ function _updateNetStats() {
1644
+ const allReqs = state.network.order.map(id => state.network.requests[id]).filter(Boolean);
1645
+ const completed = allReqs.filter(r => r.duration != null);
1646
+ const total = allReqs.length;
1647
+ const errors = allReqs.filter(r => r.phase === 'error' || (r.status && r.status >= 400)).length;
1648
+ const slow = completed.filter(r => r.duration >= 1000).length;
1649
+ const durations = completed.map(r => r.duration);
1650
+ const avg = durations.length ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
1651
+ const slowest = durations.length ? Math.max(...durations) : 0;
1652
+ const slowestReq = completed.find(r => r.duration === slowest);
1653
+ const slowestName = slowestReq ? (tryURL(slowestReq.url)?.pathname?.split('/').pop() || slowestReq.url?.split('/').pop() || '?') : '—';
1654
+
1655
+ const el = (id, text) => { const e = $(id); if (e) e.textContent = text; };
1656
+ el('netStatsTotal', `${total} requests`);
1657
+ el('netStatsAvg', `Avg: ${avg ? (avg > 999 ? `${(avg/1000).toFixed(1)}s` : `${avg}ms`) : '—'}`);
1658
+ el('netStatsSlowest', `Slowest: ${slowest ? (slowest > 999 ? `${(slowest/1000).toFixed(1)}s` : `${slowest}ms`) + ` (${slowestName})` : '—'}`);
1659
+ el('netStatsErrors', `Errors: ${errors}`);
1660
+ el('netStatsSlow', `Slow (>1s): ${slow}`);
1661
+ // Highlight if there are slow or errored requests
1662
+ if (slow > 0) $('netStatsSlow')?.classList.add('warn');
1663
+ else $('netStatsSlow')?.classList.remove('warn');
1664
+ if (errors > 0) $('netStatsErrors')?.classList.add('err');
1665
+ else $('netStatsErrors')?.classList.remove('err');
1322
1666
  }
1323
1667
 
1324
1668
  function _isHttpError(r) {
@@ -1327,7 +1671,9 @@ function _isHttpError(r) {
1327
1671
 
1328
1672
  function buildNetRow(r, wfMin, wfRange) {
1329
1673
  const row = document.createElement('div');
1330
- row.className = 'net-row' + (r.id === state.network.selectedId ? ' selected' : '') + (_isHttpError(r) ? ' error' : '');
1674
+ const rowSlow = !_isHttpError(r) && (r.duration || 0) >= 1000;
1675
+ const rowVerySlow = !_isHttpError(r) && (r.duration || 0) >= 3000;
1676
+ row.className = 'net-row' + (r.id === state.network.selectedId ? ' selected' : '') + (_isHttpError(r) ? ' error' : '') + (rowVerySlow ? ' very-slow' : rowSlow ? ' slow' : '');
1331
1677
  row.dataset.id = r.id;
1332
1678
 
1333
1679
  const urlObj = tryURL(r.url);
@@ -1394,7 +1740,9 @@ function buildNetRow(r, wfMin, wfRange) {
1394
1740
 
1395
1741
  // Time
1396
1742
  const timeCell = document.createElement('div');
1397
- timeCell.className = 'net-cell net-time' + ((r.duration || 0) > 1500 ? ' slow' : '');
1743
+ const dur = r.duration || 0;
1744
+ const slowClass = dur >= 3000 ? ' very-slow' : dur >= 1000 ? ' slow' : '';
1745
+ timeCell.className = 'net-cell net-time' + slowClass;
1398
1746
  timeCell.dataset.col = 'time';
1399
1747
  timeCell.style.width = NET_COLS[5].width + 'px';
1400
1748
  timeCell.textContent = r.duration != null ? (r.duration > 999 ? `${(r.duration/1000).toFixed(1)}s` : `${r.duration}ms`) : '...';
@@ -1589,6 +1937,12 @@ function showNetContextMenu(e, r) {
1589
1937
  navigator.clipboard.writeText(text);
1590
1938
  }});
1591
1939
  }
1940
+ // Hide URL option
1941
+ items.push({ label: '—', action: null }); // separator
1942
+ items.push({ label: 'Hide this URL', action: () => {
1943
+ addHiddenURL(r.url || '');
1944
+ renderNetwork();
1945
+ }});
1592
1946
  showContextMenu(e, items);
1593
1947
  }
1594
1948
 
@@ -1634,7 +1988,12 @@ function initGA4Panel() {
1634
1988
  <span class="panel-label">GA4 Events</span>
1635
1989
  <span class="badge" id="ga4Badge">0</span>
1636
1990
  <input id="ga4Search" class="net-search-input" style="margin-left:12px" placeholder="Filter events..." />
1637
- <div class="ml-auto">
1991
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
1992
+ <label class="toggle-label" for="ga4ColorToggle" style="font-size:10px;gap:4px">
1993
+ <span style="color:var(--text-dim)">Colors</span>
1994
+ <input type="checkbox" id="ga4ColorToggle" class="toggle-input" ${getGA4ColorsEnabled() ? 'checked' : ''} />
1995
+ <span class="toggle-slider"></span>
1996
+ </label>
1638
1997
  <button class="panel-clear-btn" id="ga4Clear" title="Clear GA4 events">Clear</button>
1639
1998
  </div>
1640
1999
  </div>
@@ -1670,6 +2029,12 @@ function initGA4Panel() {
1670
2029
  renderGA4Summary(); // update active chip highlight
1671
2030
  });
1672
2031
 
2032
+ $('ga4ColorToggle')?.addEventListener('change', (e) => {
2033
+ setGA4ColorsEnabled(e.target.checked);
2034
+ renderGA4List();
2035
+ renderGA4Summary();
2036
+ });
2037
+
1673
2038
  $('ga4Clear').addEventListener('click', () => {
1674
2039
  ga4State.events = [];
1675
2040
  ga4State.selected = -1;
@@ -1709,6 +2074,7 @@ function initGA4Panel() {
1709
2074
  }
1710
2075
 
1711
2076
  function handleGA4Event(event) {
2077
+ if (!isTabEnabled('ga4')) return;
1712
2078
  ga4State.events.push({
1713
2079
  name: event.name || '?',
1714
2080
  params: event.params || {},
@@ -1729,6 +2095,38 @@ function handleGA4Event(event) {
1729
2095
  }
1730
2096
  }
1731
2097
 
2098
+ // Assign consistent color to each GA4 event name
2099
+ const _ga4EventColors = {};
2100
+ const _ga4ColorPalette = [
2101
+ '#4facff', // blue
2102
+ '#3dd68c', // green
2103
+ '#ff813f', // orange
2104
+ '#c678dd', // purple
2105
+ '#e06c75', // coral
2106
+ '#56b6c2', // teal
2107
+ '#d19a66', // gold
2108
+ '#98c379', // lime
2109
+ '#e5c07b', // yellow
2110
+ '#ff5e72', // red
2111
+ '#61afef', // light blue
2112
+ '#be5046', // rust
2113
+ ];
2114
+ let _ga4ColorIdx = 0;
2115
+ function _ga4EventColor(name) {
2116
+ if (!getGA4ColorsEnabled()) return ''; // empty = inherit default text color
2117
+ if (!_ga4EventColors[name]) {
2118
+ _ga4EventColors[name] = _ga4ColorPalette[_ga4ColorIdx % _ga4ColorPalette.length];
2119
+ _ga4ColorIdx++;
2120
+ }
2121
+ return _ga4EventColors[name];
2122
+ }
2123
+ function getGA4ColorsEnabled() {
2124
+ try { return localStorage.getItem('rn-debug-ga4-colors') === 'true'; } catch { return false; }
2125
+ }
2126
+ function setGA4ColorsEnabled(v) {
2127
+ try { localStorage.setItem('rn-debug-ga4-colors', v ? 'true' : 'false'); } catch {}
2128
+ }
2129
+
1732
2130
  function renderGA4List() {
1733
2131
  const list = $('ga4List');
1734
2132
  const empty = $('ga4Empty');
@@ -1758,9 +2156,11 @@ function renderGA4List() {
1758
2156
 
1759
2157
  const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 });
1760
2158
 
2159
+ const evtColor = _ga4EventColor(e.name);
2160
+ const colorStyle = evtColor ? `color:${evtColor}` : '';
1761
2161
  row.innerHTML = `
1762
2162
  <span class="ga4-cell ga4-time">${time}</span>
1763
- <span class="ga4-cell ga4-name">${esc(e.name)}</span>`;
2163
+ <span class="ga4-cell ga4-name" style="${colorStyle}">${esc(e.name)}</span>`;
1764
2164
 
1765
2165
  row.addEventListener('click', () => {
1766
2166
  ga4State.selected = e.index;
@@ -1795,7 +2195,7 @@ function renderGA4Detail(e) {
1795
2195
  const header = document.createElement('div');
1796
2196
  header.className = 'ga4-detail-info';
1797
2197
  header.innerHTML = `
1798
- <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>
2198
+ <div class="ga4-detail-row"><span class="ga4-detail-key">Event Name</span><span class="ga4-detail-val" style="${_ga4EventColor(e.name) ? 'color:' + _ga4EventColor(e.name) + ';' : ''}font-weight:600;font-size:1.1em">${esc(e.name)}</span></div>
1799
2199
  <div class="ga4-detail-row"><span class="ga4-detail-key">Timestamp</span><span class="ga4-detail-val">${time}</span></div>
1800
2200
  `;
1801
2201
  detail.appendChild(header);
@@ -1869,8 +2269,15 @@ function renderGA4Summary() {
1869
2269
  sorted.forEach(([name, count]) => {
1870
2270
  const chip = document.createElement('span');
1871
2271
  const isActive = ga4State.searchFilter === name.toLowerCase();
2272
+ const chipColor = _ga4EventColor(name);
1872
2273
  chip.className = 'ga4-summary-chip' + (isActive ? ' active' : '');
1873
- chip.innerHTML = `<b>${esc(name)}</b><span class="chip-count">${count}</span>`;
2274
+ if (chipColor) {
2275
+ chip.style.borderColor = chipColor;
2276
+ if (isActive) chip.style.background = chipColor + '22';
2277
+ chip.innerHTML = `<b style="color:${chipColor}">${esc(name)}</b><span class="chip-count">${count}</span>`;
2278
+ } else {
2279
+ chip.innerHTML = `<b>${esc(name)}</b><span class="chip-count">${count}</span>`;
2280
+ }
1874
2281
  chip.addEventListener('click', () => {
1875
2282
  const search = $('ga4Search');
1876
2283
  if (isActive) {
@@ -2051,9 +2458,8 @@ function _createHighlightedTree(key, val, changedPaths, currentPath, isOld) {
2051
2458
 
2052
2459
  const children = document.createElement('div');
2053
2460
  children.className = 'ov-children';
2054
- // Auto-expand if this node has changed descendants, otherwise collapse
2055
- children.style.display = hasChangedDescendant ? 'block' : 'none';
2056
- if (hasChangedDescendant) { arrow.textContent = '\u25BC'; arrow.classList.add('open'); }
2461
+ // Always start collapsed user expands what they need
2462
+ children.style.display = 'none';
2057
2463
 
2058
2464
  let populated = false;
2059
2465
  function populate() {
@@ -2065,9 +2471,6 @@ function _createHighlightedTree(key, val, changedPaths, currentPath, isOld) {
2065
2471
  });
2066
2472
  }
2067
2473
 
2068
- // Populate immediately if expanded, otherwise lazy
2069
- if (hasChangedDescendant) populate();
2070
-
2071
2474
  header.addEventListener('click', (e) => {
2072
2475
  e.stopPropagation();
2073
2476
  const open = children.style.display !== 'none';
@@ -2082,6 +2485,8 @@ function _createHighlightedTree(key, val, changedPaths, currentPath, isOld) {
2082
2485
 
2083
2486
  function handleReduxEvent(event) {
2084
2487
  if (event.type !== 'redux') return;
2488
+ // Skip processing if Redux tab is disabled (saves memory)
2489
+ if (!isTabEnabled('redux')) return;
2085
2490
  const { action, nextState } = event;
2086
2491
  const idx = state.redux.actions.length;
2087
2492
 
@@ -2095,6 +2500,16 @@ function handleReduxEvent(event) {
2095
2500
  const actionEntry = { type: action?.type || '?', payload: action, ts: event.ts, index: idx, changedKeys };
2096
2501
  state.redux.actions.push(actionEntry);
2097
2502
  state.redux.states.push(nextState);
2503
+ // Cap Redux history to prevent memory leak (full state stored per action)
2504
+ const MAX_REDUX_HISTORY = 500;
2505
+ if (state.redux.actions.length > MAX_REDUX_HISTORY) {
2506
+ const trim = state.redux.actions.length - MAX_REDUX_HISTORY;
2507
+ state.redux.actions.splice(0, trim);
2508
+ state.redux.states.splice(0, trim);
2509
+ // Re-index remaining actions
2510
+ state.redux.actions.forEach((a, i) => a.index = i);
2511
+ if (state.redux.selected >= 0) state.redux.selected = Math.max(0, state.redux.selected - trim);
2512
+ }
2098
2513
  // Don't auto-select — keep all collapsed until user clicks
2099
2514
  $('rBadge').textContent = state.redux.actions.length;
2100
2515
  renderRedux();
@@ -2171,7 +2586,7 @@ function renderRedux() {
2171
2586
  } else {
2172
2587
  typeHtml = `<span class="rdx-type">${esc(a.type)}</span>`;
2173
2588
  }
2174
- header.innerHTML = `<span class="rdx-index">#${a.index}</span>${typeHtml}${changesBadge}<span class="rdx-time">${ts(a.ts)}</span>`;
2589
+ header.innerHTML = `<span class="rdx-index">#${a.index}</span>${typeHtml}<span class="rdx-header-right">${changesBadge}<span class="rdx-time">${ts(a.ts)}</span></span>`;
2175
2590
  // Toggle: click to expand, click again to collapse
2176
2591
  header.addEventListener('click', () => {
2177
2592
  state.redux.selected = isSelected ? -1 : a.index;
@@ -2195,6 +2610,18 @@ function renderRedux() {
2195
2610
  const detail = document.createElement('div');
2196
2611
  detail.className = 'rdx-entry-detail';
2197
2612
 
2613
+ // Close button
2614
+ const closeBtn = document.createElement('button');
2615
+ closeBtn.className = 'rdx-close-btn';
2616
+ closeBtn.textContent = '✕';
2617
+ closeBtn.title = 'Close';
2618
+ closeBtn.addEventListener('click', (e) => {
2619
+ e.stopPropagation();
2620
+ state.redux.selected = -1;
2621
+ renderRedux();
2622
+ });
2623
+ detail.appendChild(closeBtn);
2624
+
2198
2625
  // Changed keys badges
2199
2626
  if (a.changedKeys?.length > 0) {
2200
2627
  const keysEl = document.createElement('div');
@@ -2213,8 +2640,7 @@ function renderRedux() {
2213
2640
  detail.appendChild(createTreeNode(null, a.payload, false));
2214
2641
  }
2215
2642
 
2216
- // Store changes — show full Previous and Current state for each changed key
2217
- // with changed sub-keys highlighted
2643
+ // Store changes — two-column layout: Previous | Current
2218
2644
  const prevS = a.index > 0 ? states[a.index - 1] : null;
2219
2645
  const currS = states[a.index];
2220
2646
  if (currS && typeof currS === 'object' && a.changedKeys?.length > 0) {
@@ -2234,30 +2660,63 @@ function renderRedux() {
2234
2660
  const changedPaths = new Set();
2235
2661
  _findLeafChanges(oldVal, newVal, '').forEach(c => changedPaths.add(c.path));
2236
2662
 
2237
- // Previous state
2663
+ // Two-column grid: Previous | Current
2664
+ const grid = document.createElement('div');
2665
+ grid.className = 'rdx-diff-grid';
2666
+
2667
+ // Previous column
2668
+ const prevCol = document.createElement('div');
2669
+ prevCol.className = 'rdx-diff-col prev';
2670
+ const prevLabel = document.createElement('div');
2671
+ prevLabel.className = 'rdx-state-label prev';
2672
+ prevLabel.textContent = '- Previous';
2673
+ prevCol.appendChild(prevLabel);
2238
2674
  if (oldVal !== undefined) {
2239
- const prevLabel = document.createElement('div');
2240
- prevLabel.className = 'rdx-state-label prev';
2241
- prevLabel.textContent = '- Previous';
2242
- keyWrap.appendChild(prevLabel);
2243
- const prevTree = document.createElement('div');
2244
- prevTree.className = 'rdx-state-tree prev';
2245
- prevTree.appendChild(_createHighlightedTree(null, oldVal, changedPaths, '', true));
2246
- keyWrap.appendChild(prevTree);
2675
+ prevCol.appendChild(_createHighlightedTree(null, oldVal, changedPaths, '', true));
2676
+ } else {
2677
+ const na = document.createElement('span');
2678
+ na.style.cssText = 'color:var(--text-dim);font-size:10px;font-style:italic';
2679
+ na.textContent = 'undefined';
2680
+ prevCol.appendChild(na);
2247
2681
  }
2248
-
2249
- // Current state
2682
+ grid.appendChild(prevCol);
2683
+
2684
+ // Current column
2685
+ const currCol = document.createElement('div');
2686
+ currCol.className = 'rdx-diff-col curr';
2687
+ const currLabel = document.createElement('div');
2688
+ currLabel.className = 'rdx-state-label curr';
2689
+ currLabel.textContent = '+ Current';
2690
+ currCol.appendChild(currLabel);
2250
2691
  if (newVal !== undefined) {
2251
- const currLabel = document.createElement('div');
2252
- currLabel.className = 'rdx-state-label curr';
2253
- currLabel.textContent = '+ Current';
2254
- keyWrap.appendChild(currLabel);
2255
- const currTree = document.createElement('div');
2256
- currTree.className = 'rdx-state-tree curr';
2257
- currTree.appendChild(_createHighlightedTree(null, newVal, changedPaths, '', false));
2258
- keyWrap.appendChild(currTree);
2692
+ currCol.appendChild(_createHighlightedTree(null, newVal, changedPaths, '', false));
2693
+ } else {
2694
+ const na = document.createElement('span');
2695
+ na.style.cssText = 'color:var(--text-dim);font-size:10px;font-style:italic';
2696
+ na.textContent = 'undefined';
2697
+ currCol.appendChild(na);
2259
2698
  }
2260
-
2699
+ grid.appendChild(currCol);
2700
+
2701
+ // Right-click to copy on each column
2702
+ prevCol.addEventListener('contextmenu', (e) => {
2703
+ e.preventDefault(); e.stopPropagation();
2704
+ showContextMenu(e, [
2705
+ { label: 'Copy Previous Value', action: () => navigator.clipboard.writeText(JSON.stringify(oldVal, null, 2)) },
2706
+ { label: 'Copy Current Value', action: () => navigator.clipboard.writeText(JSON.stringify(newVal, null, 2)) },
2707
+ { label: `Copy "${key}" key`, action: () => navigator.clipboard.writeText(key) },
2708
+ ]);
2709
+ });
2710
+ currCol.addEventListener('contextmenu', (e) => {
2711
+ e.preventDefault(); e.stopPropagation();
2712
+ showContextMenu(e, [
2713
+ { label: 'Copy Current Value', action: () => navigator.clipboard.writeText(JSON.stringify(newVal, null, 2)) },
2714
+ { label: 'Copy Previous Value', action: () => navigator.clipboard.writeText(JSON.stringify(oldVal, null, 2)) },
2715
+ { label: `Copy "${key}" key`, action: () => navigator.clipboard.writeText(key) },
2716
+ ]);
2717
+ });
2718
+
2719
+ keyWrap.appendChild(grid);
2261
2720
  detail.appendChild(keyWrap);
2262
2721
  });
2263
2722
  }
@@ -2323,6 +2782,7 @@ let _storageRAF = null;
2323
2782
 
2324
2783
  function handleStorageEvent(event) {
2325
2784
  if (event.type !== 'storage') return;
2785
+ if (!isTabEnabled('storage')) return;
2326
2786
  const { key, value, action } = event;
2327
2787
  if (action === 'set' || action === 'snapshot') {
2328
2788
  if (action === 'snapshot' && typeof key === 'object') {
@@ -2449,6 +2909,226 @@ function setStoredFontSize(s) {
2449
2909
  try { localStorage.setItem('rn-debug-fontsize', String(s)); } catch {}
2450
2910
  }
2451
2911
 
2912
+ const FONT_FAMILIES = [
2913
+ { label: 'SF Mono', value: "'SFMono-Regular', 'SF Mono', monospace" },
2914
+ { label: 'Menlo', value: "Menlo, monospace" },
2915
+ { label: 'Monaco', value: "Monaco, monospace" },
2916
+ { label: 'Courier New', value: "'Courier New', Courier, monospace" },
2917
+ { label: 'System Mono', value: "monospace" },
2918
+ ];
2919
+ function getStoredFontFamily() {
2920
+ try {
2921
+ const saved = localStorage.getItem('rn-debug-fontfamily');
2922
+ // Reset if saved value was a removed font
2923
+ if (saved && !FONT_FAMILIES.some(f => f.value === saved)) return FONT_FAMILIES[0].value;
2924
+ return saved || FONT_FAMILIES[0].value;
2925
+ } catch { return FONT_FAMILIES[0].value; }
2926
+ }
2927
+ function setStoredFontFamily(f) {
2928
+ try { localStorage.setItem('rn-debug-fontfamily', f); } catch {}
2929
+ }
2930
+ function applyFontFamily(family) {
2931
+ document.body.style.fontFamily = family;
2932
+ }
2933
+
2934
+ // ─── Hidden URLs (Network tab) ───────────────────────────────────────────────
2935
+ function getHiddenURLs() {
2936
+ try { return JSON.parse(localStorage.getItem('rn-debug-hidden-urls') || '[]'); } catch { return []; }
2937
+ }
2938
+ function setHiddenURLs(list) {
2939
+ try { localStorage.setItem('rn-debug-hidden-urls', JSON.stringify(list)); } catch {}
2940
+ }
2941
+ function addHiddenURL(url) {
2942
+ // Extract the base URL (without query params) as the pattern
2943
+ const pattern = url.split('?')[0];
2944
+ const list = getHiddenURLs();
2945
+ if (!list.includes(pattern)) {
2946
+ list.push(pattern);
2947
+ setHiddenURLs(list);
2948
+ }
2949
+ _updateHiddenBadge();
2950
+ }
2951
+ function removeHiddenURL(pattern) {
2952
+ const list = getHiddenURLs().filter(u => u !== pattern);
2953
+ setHiddenURLs(list);
2954
+ _updateHiddenBadge();
2955
+ }
2956
+ function isURLHidden(url) {
2957
+ const hidden = getHiddenURLs();
2958
+ if (!hidden.length) return false;
2959
+ const base = url.split('?')[0];
2960
+ return hidden.some(pattern => base === pattern || base.startsWith(pattern));
2961
+ }
2962
+ function _updateHiddenBadge() {
2963
+ const btn = $('netHiddenBtn');
2964
+ if (!btn) return;
2965
+ const count = getHiddenURLs().length;
2966
+ btn.textContent = count > 0 ? `Hidden (${count})` : 'Hidden';
2967
+ btn.style.display = count > 0 ? '' : 'none';
2968
+ }
2969
+
2970
+ // ─── Tab Visibility ──────────────────────────────────────────────────────────
2971
+ const TAB_CONFIG = [
2972
+ { id: 'console', label: 'Console', icon: '🖥', essential: true },
2973
+ { id: 'network', label: 'Network', icon: '📡', essential: true },
2974
+ { id: 'redux', label: 'Redux', icon: '🔲', essential: false },
2975
+ { id: 'ga4', label: 'GA4 Events', icon: '📊', essential: false },
2976
+ { id: 'storage', label: 'Storage', icon: '💾', essential: false },
2977
+ { id: 'memory', label: 'Memory', icon: '🧠', essential: false },
2978
+ { id: 'performance', label: 'Performance', icon: '⚡', essential: false },
2979
+ { id: 'react', label: 'React Tree', icon: '⚛️', essential: false },
2980
+ ];
2981
+ function getTabVisibility() {
2982
+ try {
2983
+ const saved = JSON.parse(localStorage.getItem('rn-debug-tab-visibility') || '{}');
2984
+ const result = {};
2985
+ TAB_CONFIG.forEach(t => { result[t.id] = saved[t.id] !== undefined ? saved[t.id] : true; });
2986
+ return result;
2987
+ } catch {
2988
+ const result = {};
2989
+ TAB_CONFIG.forEach(t => { result[t.id] = true; });
2990
+ return result;
2991
+ }
2992
+ }
2993
+ function setTabVisibility(vis) {
2994
+ try { localStorage.setItem('rn-debug-tab-visibility', JSON.stringify(vis)); } catch {}
2995
+ }
2996
+ function getTabOrder() {
2997
+ try {
2998
+ const saved = JSON.parse(localStorage.getItem('rn-debug-tab-order') || '[]');
2999
+ if (saved.length === TAB_CONFIG.length) return saved;
3000
+ } catch {}
3001
+ return TAB_CONFIG.map(t => t.id);
3002
+ }
3003
+ function setTabOrder(order) {
3004
+ try { localStorage.setItem('rn-debug-tab-order', JSON.stringify(order)); } catch {}
3005
+ }
3006
+ function applyTabVisibility() {
3007
+ const vis = getTabVisibility();
3008
+ const order = getTabOrder();
3009
+ const nav = $('sidebar');
3010
+ if (!nav) return;
3011
+ // Reorder nav buttons according to saved order + hide disabled ones
3012
+ // Settings button always stays last
3013
+ const settingsBtn = nav.querySelector('.nav-btn[data-panel="settings"]');
3014
+ const spacer = nav.querySelector('.nav-spacer');
3015
+ const anchor = spacer || settingsBtn; // insert before spacer or settings
3016
+ order.forEach(tabId => {
3017
+ const btn = nav.querySelector(`.nav-btn[data-panel="${tabId}"]`);
3018
+ if (btn) {
3019
+ btn.style.display = vis[tabId] ? '' : 'none';
3020
+ nav.insertBefore(btn, anchor);
3021
+ }
3022
+ });
3023
+ // If active panel is now hidden, switch to first visible
3024
+ if (!vis[state.activePanel]) {
3025
+ const first = order.find(id => vis[id]);
3026
+ if (first) switchPanel(first);
3027
+ }
3028
+ }
3029
+ function isTabEnabled(tabId) {
3030
+ return getTabVisibility()[tabId] !== false;
3031
+ }
3032
+
3033
+ function _buildTabVisGrid() {
3034
+ const container = $('tabVisibilityGrid');
3035
+ if (!container) return;
3036
+ container.innerHTML = '';
3037
+ const vis = getTabVisibility();
3038
+ const order = getTabOrder();
3039
+ let dragSrc = null;
3040
+
3041
+ order.forEach(tabId => {
3042
+ const t = TAB_CONFIG.find(c => c.id === tabId);
3043
+ if (!t) return;
3044
+
3045
+ const item = document.createElement('div');
3046
+ item.className = `tab-vis-item ${vis[t.id] ? 'active' : 'inactive'}`;
3047
+ item.dataset.tab = t.id;
3048
+ item.draggable = true;
3049
+
3050
+ // Drag handle
3051
+ const drag = document.createElement('span');
3052
+ drag.className = 'tab-vis-drag';
3053
+ drag.textContent = '⠿';
3054
+ item.appendChild(drag);
3055
+
3056
+ // Checkbox
3057
+ const check = document.createElement('input');
3058
+ check.type = 'checkbox';
3059
+ check.className = 'tab-vis-check';
3060
+ check.checked = vis[t.id];
3061
+ if (t.essential) check.disabled = true;
3062
+ check.addEventListener('change', () => {
3063
+ const v = getTabVisibility();
3064
+ v[t.id] = check.checked;
3065
+ setTabVisibility(v);
3066
+ applyTabVisibility();
3067
+ item.classList.toggle('active', check.checked);
3068
+ item.classList.toggle('inactive', !check.checked);
3069
+ });
3070
+ item.appendChild(check);
3071
+
3072
+ // Icon + label
3073
+ const icon = document.createElement('span');
3074
+ icon.className = 'tab-vis-icon';
3075
+ icon.textContent = t.icon;
3076
+ item.appendChild(icon);
3077
+
3078
+ const label = document.createElement('span');
3079
+ label.className = 'tab-vis-label';
3080
+ label.textContent = t.label;
3081
+ item.appendChild(label);
3082
+
3083
+ if (t.essential) {
3084
+ const req = document.createElement('span');
3085
+ req.className = 'tab-vis-required';
3086
+ req.textContent = 'Required';
3087
+ item.appendChild(req);
3088
+ }
3089
+
3090
+ // Drag events
3091
+ item.addEventListener('dragstart', (e) => {
3092
+ dragSrc = item;
3093
+ item.classList.add('dragging');
3094
+ e.dataTransfer.effectAllowed = 'move';
3095
+ });
3096
+ item.addEventListener('dragend', () => {
3097
+ item.classList.remove('dragging');
3098
+ container.querySelectorAll('.tab-vis-item').forEach(el => el.classList.remove('drag-over'));
3099
+ dragSrc = null;
3100
+ });
3101
+ item.addEventListener('dragover', (e) => {
3102
+ e.preventDefault();
3103
+ e.dataTransfer.dropEffect = 'move';
3104
+ if (dragSrc && dragSrc !== item) item.classList.add('drag-over');
3105
+ });
3106
+ item.addEventListener('dragleave', () => {
3107
+ item.classList.remove('drag-over');
3108
+ });
3109
+ item.addEventListener('drop', (e) => {
3110
+ e.preventDefault();
3111
+ item.classList.remove('drag-over');
3112
+ if (!dragSrc || dragSrc === item) return;
3113
+ // Reorder: move dragSrc before or after this item
3114
+ const items = [...container.querySelectorAll('.tab-vis-item')];
3115
+ const fromIdx = items.indexOf(dragSrc);
3116
+ const toIdx = items.indexOf(item);
3117
+ if (fromIdx < toIdx) {
3118
+ container.insertBefore(dragSrc, item.nextSibling);
3119
+ } else {
3120
+ container.insertBefore(dragSrc, item);
3121
+ }
3122
+ // Save new order
3123
+ const newOrder = [...container.querySelectorAll('.tab-vis-item')].map(el => el.dataset.tab);
3124
+ setTabOrder(newOrder);
3125
+ applyTabVisibility();
3126
+ });
3127
+
3128
+ container.appendChild(item);
3129
+ });
3130
+ }
3131
+
2452
3132
  function getStoredAppName() {
2453
3133
  try { return localStorage.getItem('rn-debug-appname') || 'ReactoRadar'; } catch { return 'ReactoRadar'; }
2454
3134
  }
@@ -2513,108 +3193,119 @@ function initSettingsPanel() {
2513
3193
  const panel = $('panel-settings');
2514
3194
  const current = getStoredTheme();
2515
3195
  const currentSize = getStoredFontSize();
2516
- panel.innerHTML = `
3196
+ panel.innerHTML = `
2517
3197
  <div class="panel-toolbar">
2518
3198
  <span class="panel-label">Settings</span>
2519
3199
  </div>
2520
3200
  <div class="scroll-area">
2521
- <div class="settings-content">
2522
- <div class="settings-section">
2523
- <div class="settings-section-title">Appearance</div>
2524
- <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
2525
- <div>
2526
- <div class="settings-label">Theme</div>
2527
- <div class="settings-hint">Choose a color theme for the debugger</div>
3201
+ <div class="settings-two-col">
3202
+ <div class="settings-col-left">
3203
+ <div class="settings-section">
3204
+ <div class="settings-section-title">Appearance</div>
3205
+ <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
3206
+ <div>
3207
+ <div class="settings-label">Theme</div>
3208
+ <div class="settings-hint">Choose a color theme</div>
3209
+ </div>
3210
+ <div class="theme-grid" id="themeSwitcher"></div>
2528
3211
  </div>
2529
- <div class="theme-grid" id="themeSwitcher"></div>
2530
- </div>
2531
- <div class="settings-row">
2532
- <div>
2533
- <div class="settings-label">Font Size</div>
2534
- <div class="settings-hint">Adjust text size across all panels</div>
3212
+ <div class="settings-row">
3213
+ <div>
3214
+ <div class="settings-label">Font Size</div>
3215
+ <div class="settings-hint">Adjust text size</div>
3216
+ </div>
3217
+ <div class="font-size-control">
3218
+ <button class="font-size-btn" id="fontSizeDown">A-</button>
3219
+ <span class="font-size-display" id="fontSizeDisplay">${currentSize}px</span>
3220
+ <button class="font-size-btn" id="fontSizeUp">A+</button>
3221
+ </div>
2535
3222
  </div>
2536
- <div class="font-size-control">
2537
- <button class="font-size-btn" id="fontSizeDown">A-</button>
2538
- <span class="font-size-display" id="fontSizeDisplay">${currentSize}px</span>
2539
- <button class="font-size-btn" id="fontSizeUp">A+</button>
3223
+ <div class="settings-row">
3224
+ <div>
3225
+ <div class="settings-label">Font Family</div>
3226
+ </div>
3227
+ <select id="fontFamilySelect" class="net-throttle-select" style="width:150px">
3228
+ ${FONT_FAMILIES.map(f => `<option value="${esc(f.value)}" ${f.value === getStoredFontFamily() ? 'selected' : ''}>${esc(f.label)}</option>`).join('')}
3229
+ </select>
2540
3230
  </div>
2541
- </div>
2542
- <div class="settings-row">
2543
- <div>
2544
- <div class="settings-label">App Name</div>
2545
- <div class="settings-hint">Customize the app title (visible in titlebar)</div>
3231
+ <div class="settings-row">
3232
+ <div>
3233
+ <div class="settings-label">App Name</div>
3234
+ </div>
3235
+ <div style="display:flex;align-items:center;gap:6px">
3236
+ <input id="appNameInput" class="net-search-input" style="width:120px;text-align:center" value="${getStoredAppName()}" />
3237
+ <button class="font-size-btn" id="appNameReset" title="Reset">Reset</button>
3238
+ </div>
2546
3239
  </div>
2547
- <div style="display:flex;align-items:center;gap:6px">
2548
- <input id="appNameInput" class="net-search-input" style="width:140px;text-align:center" value="${getStoredAppName()}" />
2549
- <button class="font-size-btn" id="appNameReset" title="Reset to default">Reset</button>
3240
+ <div class="settings-row">
3241
+ <div>
3242
+ <div class="settings-label">Toast Notifications</div>
3243
+ <div class="settings-hint">Show alerts for API errors and slow requests</div>
3244
+ </div>
3245
+ <label class="toggle-label" for="toastToggle">
3246
+ <input type="checkbox" id="toastToggle" class="toggle-input" ${getToastsEnabled() ? 'checked' : ''} />
3247
+ <span class="toggle-slider"></span>
3248
+ </label>
2550
3249
  </div>
2551
3250
  </div>
2552
- </div>
2553
- <div class="settings-section">
2554
- <div class="settings-section-title">Connection</div>
2555
- <div class="settings-row">
2556
- <div>
2557
- <div class="settings-label">Bridge Ports</div>
2558
- <div class="settings-hint">Redux :9090 &middot; Storage :9091 &middot; Network :9092 &middot; React DT :8097</div>
3251
+ <div class="settings-section">
3252
+ <div class="settings-section-title">Connection</div>
3253
+ <div class="settings-row">
3254
+ <div>
3255
+ <div class="settings-label">Bridge Ports</div>
3256
+ <div class="settings-hint">Redux :9090 · Storage :9091 · Network :9092</div>
3257
+ </div>
3258
+ </div>
3259
+ <div class="settings-row">
3260
+ <div>
3261
+ <div class="settings-label">Metro Port</div>
3262
+ </div>
3263
+ <input id="metroPortInput" type="number" class="net-search-input" style="width:70px;text-align:center" value="${getStoredMetroPort()}" />
2559
3264
  </div>
2560
3265
  </div>
2561
- <div class="settings-row">
2562
- <div style="display:flex;flex-direction:column;gap:2px">
2563
- <div class="settings-label">Metro Bundler Port</div>
2564
- <div class="settings-hint">Port for CDP target discovery (default: 8081)</div>
3266
+ <div class="settings-section">
3267
+ <div class="settings-section-title">About</div>
3268
+ <div class="settings-about">
3269
+ <div class="about-name" id="aboutAppName">${getStoredAppName()}</div>
3270
+ <div class="about-version" id="aboutVersion">v${state._appVersion || '...'}</div>
3271
+ <div class="about-desc">Standalone macOS debugger for React Native.<br/>Supports Hermes, New Arch, and RN 0.74+.</div>
3272
+ <div class="about-links" style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
3273
+ <span class="about-link" id="linkGithub">GitHub</span>
3274
+ <span class="about-link" id="linkDocs">Docs</span>
3275
+ <span class="about-link" id="linkLinkedIn">LinkedIn</span>
3276
+ </div>
3277
+ <div style="margin-top:12px;text-align:center">
3278
+ <button class="support-btn" id="linkSupport" title="Support ReactoRadar development">☕ Support this project</button>
3279
+ </div>
2565
3280
  </div>
2566
- <input id="metroPortInput" type="number" class="net-search-input" style="width:70px;text-align:center" value="${getStoredMetroPort()}" />
2567
3281
  </div>
2568
3282
  </div>
2569
- <div class="settings-section">
2570
- <div class="settings-section-title">Keyboard Shortcuts</div>
2571
- <div class="settings-row">
2572
- <div class="settings-label">Clear Active Tab</div>
2573
- <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">Clear button</div>
2574
- </div>
2575
- <div class="settings-row">
2576
- <div class="settings-label">Clear All</div>
2577
- <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;K</div>
2578
- </div>
2579
- <div class="settings-row">
2580
- <div class="settings-label">Open JS Debugger</div>
2581
- <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;D</div>
3283
+ <div class="settings-col-right">
3284
+ <div class="settings-section">
3285
+ <div class="settings-section-title">Panels</div>
3286
+ <div class="settings-hint" style="margin-bottom:8px">Show/hide tabs and drag to reorder. Disabled tabs save memory.</div>
3287
+ <div class="tab-visibility-grid" id="tabVisibilityGrid"></div>
2582
3288
  </div>
2583
- <div class="settings-row">
2584
- <div class="settings-label">Open React DevTools</div>
2585
- <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;R</div>
2586
- </div>
2587
- <div class="settings-row">
2588
- <div class="settings-label">Toggle Theme</div>
2589
- <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;&#8679;T</div>
2590
- </div>
2591
- <div class="settings-row">
2592
- <div class="settings-label">Zoom In / Out</div>
2593
- <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;+ / &#8984;-</div>
2594
- </div>
2595
- </div>
2596
- <div class="settings-section">
2597
- <div class="settings-section-title">How to Use</div>
2598
- <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
2599
- <div class="settings-hint" style="line-height:1.8">
2600
- <b style="color:var(--text)">1. Setup</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar setup</code> from your RN project<br/>
2601
- <b style="color:var(--text)">2. Start</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar</code> or open ReactoRadar.app<br/>
2602
- <b style="color:var(--text)">3. Run your app</b> — <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx react-native start --reset-cache</code><br/>
2603
- <b style="color:var(--text)">4. Debug</b> — Console, Network, Redux data flows automatically<br/>
2604
- <b style="color:var(--text)">5. Remove</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar remove</code> to clean uninstall
3289
+ <div class="settings-section">
3290
+ <div class="settings-section-title">Keyboard Shortcuts</div>
3291
+ <div class="settings-shortcut-grid">
3292
+ <span class="sc-key">⌘K</span><span class="sc-label">Clear All</span>
3293
+ <span class="sc-key">⌘D</span><span class="sc-label">JS Debugger</span>
3294
+ <span class="sc-key">⌘R</span><span class="sc-label">React DevTools</span>
3295
+ <span class="sc-key">⌘⇧T</span><span class="sc-label">Toggle Theme</span>
3296
+ <span class="sc-key">⌘F</span><span class="sc-label">Find</span>
3297
+ <span class="sc-key">⌘1–9</span><span class="sc-label">Switch Panels</span>
3298
+ <span class="sc-key">⌘+/−</span><span class="sc-label">Zoom</span>
2605
3299
  </div>
2606
3300
  </div>
2607
- </div>
2608
- <div class="settings-section">
2609
- <div class="settings-section-title">About</div>
2610
- <div class="settings-about">
2611
- <div class="about-name" id="aboutAppName">${getStoredAppName()}</div>
2612
- <div class="about-version" id="aboutVersion">v${state._appVersion || '...'}</div>
2613
- <div class="about-desc">A standalone macOS debugger for React Native apps.<br/>Supports Hermes, New Architecture, and React Native 0.74+.</div>
2614
- <div class="about-links" style="display:flex;gap:16px;justify-content:center">
2615
- <span class="about-link" id="linkGithub">GitHub</span>
2616
- <span class="about-link" id="linkDocs">Documentation</span>
2617
- <span class="about-link" id="linkLinkedIn">Developer LinkedIn</span>
3301
+ <div class="settings-section">
3302
+ <div class="settings-section-title">Quick Start</div>
3303
+ <div class="settings-hint" style="line-height:1.8;font-size:11px">
3304
+ <b style="color:var(--text)">1.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar setup</code><br/>
3305
+ <b style="color:var(--text)">2.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar</code> or open app<br/>
3306
+ <b style="color:var(--text)">3.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx react-native start</code><br/>
3307
+ <b style="color:var(--text)">4.</b> Console, Network, Redux auto-connect<br/>
3308
+ <b style="color:var(--text)">5.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar remove</code> to uninstall
2618
3309
  </div>
2619
3310
  </div>
2620
3311
  </div>
@@ -2633,6 +3324,9 @@ function initSettingsPanel() {
2633
3324
  { id: 'github-dark', name: 'GitHub Dark', colors: ['#0d1117','#58a6ff','#3fb950','#f85149'] },
2634
3325
  { id: 'one-dark', name: 'One Dark', colors: ['#282c34','#61afef','#98c379','#e06c75'] },
2635
3326
  ];
3327
+ // Tab visibility + drag reorder
3328
+ _buildTabVisGrid();
3329
+
2636
3330
  const grid = $('themeSwitcher');
2637
3331
  themes.forEach(t => {
2638
3332
  const btn = document.createElement('button');
@@ -2667,6 +3361,9 @@ function initSettingsPanel() {
2667
3361
  $('linkLinkedIn')?.addEventListener('click', () => {
2668
3362
  window.electronAPI?.openExternal('https://www.linkedin.com/in/sharanagoudamk/');
2669
3363
  });
3364
+ $('linkSupport')?.addEventListener('click', () => {
3365
+ window.electronAPI?.openExternal('https://razorpay.me/@reactoradar');
3366
+ });
2670
3367
 
2671
3368
  // App name
2672
3369
  $('appNameInput').addEventListener('change', (e) => {
@@ -2703,14 +3400,64 @@ function initSettingsPanel() {
2703
3400
  applyFontSize(size);
2704
3401
  });
2705
3402
 
3403
+ // Font family
3404
+ $('fontFamilySelect')?.addEventListener('change', (e) => {
3405
+ const family = e.target.value;
3406
+ setStoredFontFamily(family);
3407
+ applyFontFamily(family);
3408
+ });
3409
+
3410
+ // Toast toggle
3411
+ $('toastToggle')?.addEventListener('change', (e) => {
3412
+ setToastsEnabled(e.target.checked);
3413
+ });
3414
+
2706
3415
  // Apply update banner if update info arrived before settings panel was created
2707
3416
  _applyUpdateBanner();
2708
3417
  }
2709
3418
 
2710
- // Apply saved theme + font size + app name on load
3419
+ // ─── Memory Monitor ──────────────────────────────────────────────────────────
3420
+ // Check memory usage periodically and warn user before it causes blank screen
3421
+ let _memoryWarningShown = false;
3422
+ setInterval(() => {
3423
+ if (!window.performance || !performance.memory) return;
3424
+ const used = performance.memory.usedJSHeapSize;
3425
+ const limit = performance.memory.jsHeapSizeLimit;
3426
+ const pct = used / limit;
3427
+ // Warn at 70% usage
3428
+ if (pct > 0.7 && !_memoryWarningShown) {
3429
+ _memoryWarningShown = true;
3430
+ const banner = document.createElement('div');
3431
+ banner.id = 'memoryWarning';
3432
+ banner.className = 'memory-warning';
3433
+ const usedMB = Math.round(used / 1024 / 1024);
3434
+ banner.innerHTML = `<span>High memory usage (${usedMB}MB) — ReactoRadar may become unresponsive.</span>`
3435
+ + `<button class="memory-warn-btn" id="memWarnClear">Clear All Data</button>`
3436
+ + `<button class="memory-warn-btn" id="memWarnDismiss">Dismiss</button>`;
3437
+ document.body.prepend(banner);
3438
+ $('memWarnClear')?.addEventListener('click', () => {
3439
+ // Clear all panel data
3440
+ state.console.logs = []; _consolePending = [];
3441
+ _lastLogMsg = ''; _lastLogRow = null; _lastLogCount = 1;
3442
+ $('cBadge').textContent = '0'; renderConsole();
3443
+ state.network.requests = {}; state.network.order = []; state.network.selectedId = null;
3444
+ $('nBadge').textContent = '0'; renderNetwork();
3445
+ state.redux.actions = []; state.redux.states = []; state.redux.selected = -1;
3446
+ $('rBadge').textContent = '0'; renderRedux();
3447
+ banner.remove(); _memoryWarningShown = false;
3448
+ });
3449
+ $('memWarnDismiss')?.addEventListener('click', () => { banner.remove(); });
3450
+ }
3451
+ // Reset flag when memory drops
3452
+ if (pct < 0.5) _memoryWarningShown = false;
3453
+ }, 30000); // Check every 30 seconds
3454
+
3455
+ // Apply saved theme + font size + font family + app name on load
2711
3456
  applyTheme(getStoredTheme());
2712
3457
  applyFontSize(getStoredFontSize());
3458
+ applyFontFamily(getStoredFontFamily());
2713
3459
  applyAppName(getStoredAppName());
3460
+ applyTabVisibility();
2714
3461
 
2715
3462
  // Send stored metro port to backend
2716
3463
  window.electronAPI?.setMetroPort(getStoredMetroPort());
@@ -2925,8 +3672,7 @@ function buildSourceTreeNode(name, value, depth) {
2925
3672
  });
2926
3673
  }
2927
3674
 
2928
- if (!startCollapsed) populate();
2929
-
3675
+ // Folders start collapsed — populate lazily on first expand
2930
3676
  header.addEventListener('click', () => {
2931
3677
  const isOpen = children.style.display !== 'none';
2932
3678
  if (!isOpen) {
@@ -3111,6 +3857,7 @@ function drawPerfGraph(canvasId, data, maxVal, color) {
3111
3857
 
3112
3858
  // Handle performance events from SDK (always updates meters, graphs only when recording)
3113
3859
  function handlePerfEvent(event) {
3860
+ if (!isTabEnabled('performance') && !isTabEnabled('memory')) return;
3114
3861
  if (event.fps != null) {
3115
3862
  perfState.fps.push(event.fps);
3116
3863
  if (perfState.fps.length > 100) perfState.fps.shift();