reactoradar 1.6.5 → 1.6.6

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
@@ -136,8 +136,6 @@ document.addEventListener('keydown', (e) => {
136
136
  }
137
137
  });
138
138
 
139
- // Global filter removed — each panel has its own search input
140
-
141
139
  // ─── Clear (each panel has its own clear button now) ─────────────────────────
142
140
 
143
141
  function clearActiveTab() {
@@ -238,23 +236,23 @@ function clearAll() {
238
236
  if (ga4Detail) ga4Detail.innerHTML = '';
239
237
  // Native logs
240
238
  _nativeState.logs = [];
241
- const nativeList = $('nativeLogList');
242
- if (nativeList) nativeList.innerHTML = '';
239
+ const nativeList2 = $('nativeLogList');
240
+ if (nativeList2) nativeList2.innerHTML = '';
243
241
  // Performance
244
242
  perfState.fps = [];
245
243
  perfState.jsThread = [];
246
244
  perfState.uiThread = [];
247
245
  perfState.data = [];
248
- const perfFPS = $('perfFPS'); if (perfFPS) perfFPS.textContent = '—';
249
- const perfJS = $('perfJS'); if (perfJS) perfJS.textContent = '—';
250
- const perfUI = $('perfUI'); if (perfUI) perfUI.textContent = '—';
246
+ const perfFPS2 = $('perfFPS'); if (perfFPS2) perfFPS2.textContent = '—';
247
+ const perfJS2 = $('perfJS'); if (perfJS2) perfJS2.textContent = '—';
248
+ const perfUI2 = $('perfUI'); if (perfUI2) perfUI2.textContent = '—';
251
249
  clearPerfCanvas('perfFPSCanvas');
252
250
  clearPerfCanvas('perfJSCanvas');
253
251
  clearPerfCanvas('perfUICanvas');
254
252
  // Memory
255
- const memHU = $('memHeapUsed'); if (memHU) memHU.textContent = '—';
256
- const memHT = $('memHeapTotal'); if (memHT) memHT.textContent = '—';
257
- const memN = $('memNative'); if (memN) memN.textContent = '—';
253
+ const memHU2 = $('memHeapUsed'); if (memHU2) memHU2.textContent = '—';
254
+ const memHT2 = $('memHeapTotal'); if (memHT2) memHT2.textContent = '—';
255
+ const memN2 = $('memNative'); if (memN2) memN2.textContent = '—';
258
256
  // Badges
259
257
  $('cBadge').textContent = '0';
260
258
  $('nBadge').textContent = '0';
@@ -283,8 +281,13 @@ function freeMemory() {
283
281
  if (state.console.logs.length > 200) {
284
282
  state.console.logs = state.console.logs.slice(-200);
285
283
  }
286
- // Drop full Redux state snapshots (keep action metadata)
287
- state.redux.states = [];
284
+ // Trim Redux history (keep actions and states in sync — they must have same length)
285
+ if (state.redux.actions.length > 50) {
286
+ state.redux.actions = state.redux.actions.slice(-50);
287
+ state.redux.states = state.redux.states.slice(-50);
288
+ state.redux.actions.forEach((a, i) => a.index = i);
289
+ state.redux.selected = -1;
290
+ }
288
291
  // Drop storage values (keep keys for reference)
289
292
  for (const k in state.storage.entries) {
290
293
  state.storage.entries[k] = null;
@@ -306,254 +309,7 @@ function freeMemory() {
306
309
  _consolePending = [];
307
310
  }
308
311
 
309
- // ─── CDP Button ───────────────────────────────────────────────────────────────
310
- $('btnCDP')?.addEventListener('click', () => {
311
- // Tell main process to open the CDP DevTools window with the best available target
312
- window.electronAPI?.openCDPTarget(null); // null = use latest known target
313
- });
314
-
315
- // ─── Screenshot Button ────────────────────────────────────────────────────────
316
- $('btnScreenshot')?.addEventListener('click', takeScreenshot);
317
-
318
- function takeScreenshot() {
319
- const btn = $('btnScreenshot');
320
- if (!btn) return;
321
- const origText = btn.innerHTML;
322
- btn.innerHTML = '<span style="opacity:0.6">Saving...</span>';
323
- // Use Electron's native capturePage — always works, no DOM rendering issues
324
- window.electronAPI?.captureScreenshot();
325
- btn.innerHTML = '<span style="color:var(--green)">Saved!</span>';
326
- setTimeout(() => { btn.innerHTML = origText; }, 2000);
327
- }
328
-
329
- // ─────────────────────────────────────────────────────────────────────────────
330
- // IPC from Main
331
- // ─────────────────────────────────────────────────────────────────────────────
332
- if (window.electronAPI) {
333
- window.electronAPI.on('ports', ports => { state.ports = ports; });
334
-
335
- window.electronAPI.on('cdp-targets', targets => {
336
- state.cdpTargets = targets;
337
- const btn = $('btnCDP');
338
- if (btn) {
339
- const hasCDP = targets?.length > 0;
340
- const port = state.ports?.METRO || getStoredMetroPort();
341
- btn.textContent = hasCDP
342
- ? `JS Debugger (:${port}) [${targets.length}] ↗`
343
- : `JS Debugger (:${port}) ↗`;
344
- btn.style.opacity = hasCDP ? '1' : '0.5';
345
- if (hasCDP) {
346
- btn.onclick = () => window.electronAPI.openCDPTarget(targets[0].webSocketDebuggerUrl);
347
- }
348
- }
349
- });
350
-
351
- window.electronAPI.on('redux-event', handleReduxEvent);
352
- window.electronAPI.on('network-event', handleNetworkEvent);
353
- window.electronAPI.on('storage-event', handleStorageEvent);
354
-
355
- window.electronAPI.on('ga4-event', handleGA4Event);
356
-
357
- window.electronAPI.on('perf-event', event => {
358
- handlePerfEvent(event);
359
- handleMemoryEvent(event);
360
- });
361
-
362
- window.electronAPI.on('clear-all-ui', clearAll);
363
-
364
- // When all device bridges disconnect, release heavy memory but keep logs visible.
365
- // Debounced to avoid data loss during hot reloads or flaky connections.
366
- let _disconnectTimer = null;
367
- window.electronAPI.on('device-all-disconnected', () => {
368
- clearTimeout(_disconnectTimer);
369
- _disconnectTimer = setTimeout(() => {
370
- console.log('[App] All devices disconnected — freeing memory');
371
- freeMemory();
372
- }, 3000);
373
- });
374
- // Cancel pending free if a device reconnects
375
- const _cancelDisconnectTimer = () => { clearTimeout(_disconnectTimer); _disconnectTimer = null; };
376
- window.electronAPI.on('redux-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('redux', on); });
377
- window.electronAPI.on('network-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('network', on); });
378
- window.electronAPI.on('storage-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('storage', on); });
379
- window.electronAPI.on('react-dt-status', on => { updateDeviceBanner('reactDT', on); });
380
-
381
- // Cmd+F — focus the search input for the active panel
382
- function _handleFind() {
383
- // If network detail is open, focus the detail search
384
- if (state.activePanel === 'network' && state.network.selectedId) {
385
- const wrap = $('detailSearchWrap');
386
- const input = $('detailSearchInput');
387
- if (wrap && input) {
388
- wrap.style.display = 'flex';
389
- input.focus();
390
- input.select();
391
- return;
392
- }
393
- }
394
- const searchMap = {
395
- console: 'consoleSearch',
396
- network: 'netSearchInput',
397
- ga4: 'ga4Search',
398
- redux: 'reduxSearch',
399
- storage: 'storageSearch',
400
- };
401
- const inputId = searchMap[state.activePanel];
402
- if (inputId) {
403
- const el = $(inputId);
404
- if (el) { el.focus(); el.select(); }
405
- }
406
- // Also show/focus Console bottom find bar
407
- if (state.activePanel === 'console') {
408
- const bar = $('consoleFindBar');
409
- if (bar) { bar.style.display = 'flex'; $('consoleFindInput')?.focus(); }
410
- }
411
- }
412
- window.electronAPI.on('focus-search', _handleFind);
413
- // Direct keyboard fallback — Electron menu accelerators can miss in some contexts
414
- document.addEventListener('keydown', (e) => {
415
- if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
416
- e.preventDefault();
417
- _handleFind();
418
- }
419
- });
420
-
421
- window.electronAPI.on('app-version', (version, isPackaged) => {
422
- state._appVersion = version;
423
- state._isPackaged = !!isPackaged;
424
- // Update anywhere the version is displayed
425
- document.querySelectorAll('#aboutVersion').forEach(el => el.textContent = 'v' + version);
426
- });
427
-
428
- window.electronAPI.on('update-available', ({ current, latest, autoUpdate }) => {
429
- state._updateAvailable = { current, latest, autoUpdate };
430
- _applyUpdateBanner();
431
- });
432
-
433
- window.electronAPI.on('update-downloaded', ({ version }) => {
434
- state._updateDownloaded = version;
435
- _applyUpdateBanner();
436
- });
437
-
438
- window.electronAPI.on('trigger-open-cdp', () => {
439
- window.electronAPI?.openCDPTarget(null);
440
- });
441
-
442
- // Theme toggle from menu shortcut (Cmd+Shift+T)
443
- window.electronAPI.on('theme-changed', theme => {
444
- document.documentElement.setAttribute('data-theme', theme);
445
- setStoredTheme(theme);
446
- document.querySelectorAll('#themeSwitcher .theme-card')
447
- .forEach(b => b.classList.toggle('active', b.dataset.theme === theme));
448
- });
449
- }
450
-
451
312
  // ─── Device Connection Status (inline in titlebar) ───────────────────────────
452
- // Reusable — called from IPC handler AND from initSettingsPanel
453
- function _applyUpdateBanner() {
454
- const info = state._updateAvailable;
455
- if (!info) return;
456
- const { current, latest, autoUpdate } = info;
457
- const downloaded = state._updateDownloaded;
458
- const targetVersion = downloaded || latest;
459
-
460
- const el = $('aboutVersion');
461
- if (el) {
462
- if (downloaded) {
463
- el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${downloaded} ready to install</span>`;
464
- } else {
465
- el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${latest} available</span>`;
466
- }
467
- }
468
-
469
- // Remove old buttons if state changed
470
- const oldBtn = $('updateBtn');
471
- if (oldBtn && downloaded && !oldBtn.dataset.isRestart) oldBtn.parentElement?.remove();
472
- const oldChangelog = $('changelogBtn');
473
- if (oldChangelog && downloaded && !oldChangelog.dataset.updated) oldChangelog.remove();
474
-
475
- const aboutEl = document.querySelector('.settings-about');
476
- if (!aboutEl) return;
477
-
478
- // Add "What's new?" link
479
- if (!$('changelogBtn')) {
480
- const link = document.createElement('div');
481
- link.style.cssText = 'margin-top:6px;text-align:center';
482
- link.innerHTML = `<span id="changelogBtn" class="about-link" style="font-size:10px;cursor:pointer" data-updated="${downloaded ? '1' : ''}">What's new in v${targetVersion}?</span>`;
483
- aboutEl.appendChild(link);
484
- $('changelogBtn')?.addEventListener('click', () => _showChangelog(targetVersion));
485
- }
486
-
487
- // Add update button
488
- if (!$('updateBtn')) {
489
- const btn = document.createElement('div');
490
- btn.style.cssText = 'margin-top:8px;text-align:center';
491
- if (downloaded) {
492
- 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>';
493
- aboutEl.appendChild(btn);
494
- $('updateBtn')?.addEventListener('click', () => window.electronAPI?.installUpdate());
495
- } else if (autoUpdate) {
496
- btn.innerHTML = '<button id="updateBtn" class="tb-btn" style="font-size:11px;padding:6px 16px;opacity:0.7" disabled>Downloading v' + latest + '...</button>';
497
- aboutEl.appendChild(btn);
498
- } else {
499
- btn.innerHTML = '<button id="updateBtn" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Download v' + latest + '</button>';
500
- aboutEl.appendChild(btn);
501
- $('updateBtn')?.addEventListener('click', () => window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar/releases'));
502
- }
503
- }
504
- }
505
-
506
- async function _showChangelog(version) {
507
- if (!version || typeof version !== 'string') return;
508
-
509
- // Remove existing modal
510
- $('changelogModal')?.remove();
511
-
512
- const safeVersion = esc(version);
513
- const modal = document.createElement('div');
514
- modal.id = 'changelogModal';
515
- modal.className = 'changelog-modal-overlay';
516
- modal.innerHTML = `
517
- <div class="changelog-modal">
518
- <div class="changelog-header">
519
- <span class="changelog-title">What's New in v${safeVersion}</span>
520
- <button class="changelog-close" id="changelogClose">&times;</button>
521
- </div>
522
- <div class="changelog-body" id="changelogBody">
523
- <div style="color:var(--text-dim);padding:20px;text-align:center">Loading release notes...</div>
524
- </div>
525
- </div>`;
526
- document.body.appendChild(modal);
527
-
528
- // Close handlers
529
- $('changelogClose')?.addEventListener('click', () => modal.remove());
530
- modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
531
-
532
- // Fetch changelog
533
- try {
534
- const notes = await window.electronAPI?.fetchChangelog(version);
535
- const body = $('changelogBody');
536
- if (!body) return;
537
- if (!notes || typeof notes !== 'string') {
538
- body.innerHTML = '<div style="color:var(--text-dim);padding:20px;text-align:center">No release notes available.</div>';
539
- return;
540
- }
541
- if (body && notes) {
542
- // Simple markdown-like rendering
543
- body.innerHTML = notes
544
- .replace(/^### (.+)$/gm, '<h3 style="color:var(--accent);font-size:12px;font-weight:700;margin:12px 0 6px">$1</h3>')
545
- .replace(/^## (.+)$/gm, '<h2 style="color:var(--text);font-size:14px;font-weight:700;margin:16px 0 8px">$1</h2>')
546
- .replace(/^- \*\*(.+?)\*\*(.*)$/gm, '<div style="margin:3px 0;font-size:11px;line-height:1.6"><b style="color:var(--text)">$1</b><span style="color:var(--text-dim)">$2</span></div>')
547
- .replace(/^- (.+)$/gm, '<div style="margin:3px 0;font-size:11px;line-height:1.6;color:var(--text-mid)">• $1</div>')
548
- .replace(/`([^`]+)`/g, '<code style="background:var(--bg3);padding:1px 4px;border-radius:3px;color:var(--accent);font-size:10px">$1</code>')
549
- .replace(/\n\n/g, '<br/>');
550
- }
551
- } catch {
552
- const body = $('changelogBody');
553
- if (body) body.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Could not fetch release notes</div>';
554
- }
555
- }
556
-
557
313
  function updateDeviceBanner(service, connected) {
558
314
  state.connections[service] = connected;
559
315
  const el = $('deviceStatus');
@@ -571,4194 +327,12 @@ function updateDeviceBanner(service, connected) {
571
327
  }
572
328
  }
573
329
 
574
- // ─────────────────────────────────────────────────────────────────────────────
575
- // CONSOLE PANEL
576
- // ─────────────────────────────────────────────────────────────────────────────
577
- // Load saved log level filters from localStorage
578
- function getStoredLogLevels() {
579
- try {
580
- const saved = localStorage.getItem('rn-debug-log-levels');
581
- if (saved) return JSON.parse(saved);
582
- } catch {}
583
- return { log: true, info: true, warn: true, error: true, debug: true, redux: false };
584
- }
585
- function setStoredLogLevels(levels) {
586
- try { localStorage.setItem('rn-debug-log-levels', JSON.stringify(levels)); } catch {}
587
- }
588
-
589
- function initConsolePanel() {
590
- const panel = $('panel-console');
591
- const levels = getStoredLogLevels();
592
- state.console.levelFilters = levels;
593
- state.console.showRedux = !!levels.redux;
594
-
595
- panel.innerHTML = `
596
- <div class="panel-toolbar">
597
- <span class="panel-label">Console</span>
598
- <span class="badge" id="cBadge">0</span>
599
- <input id="consoleSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter logs..." />
600
- <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
601
- <button class="panel-clear-btn" id="consoleExport" title="Export logs as JSON">Export</button>
602
- <button class="panel-clear-btn" id="consoleClear" title="Clear console">Clear</button>
603
- <div class="console-level-dropdown" id="consoleLevelDropdown">
604
- <button class="console-level-btn" id="consoleLevelBtn">Levels ▾</button>
605
- <div class="console-level-menu" id="consoleLevelMenu">
606
- <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>
607
- <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>
608
- <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>
609
- <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>
610
- <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>
611
- <div style="border-top:1px solid var(--border);margin:4px 0"></div>
612
- <label class="console-level-option"><input type="checkbox" data-level="redux" ${levels.redux ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--green)"></span>Redux Actions</label>
613
- </div>
614
- </div>
615
- </div>
616
- </div>
617
- <div class="scroll-area" id="consoleList">
618
- <div class="empty-state" id="consoleEmpty">
619
- <div class="icon">⬛</div>
620
- <div class="label">No logs yet</div>
621
- <div class="hint">Logs will appear here automatically</div>
622
- </div>
623
- </div>
624
- <div class="console-find-bar" id="consoleFindBar" style="display:none">
625
- <input id="consoleFindInput" class="console-find-input" placeholder="Find in logs... (Cmd+F)" />
626
- <span id="consoleFindCount" class="console-find-count"></span>
627
- <button class="console-find-btn" id="consoleFindPrev" title="Previous">▲</button>
628
- <button class="console-find-btn" id="consoleFindNext" title="Next">▼</button>
629
- <button class="console-find-btn" id="consoleFindClose" title="Close (Esc)">✕</button>
630
- </div>`;
631
-
632
- // Search filter
633
- $('consoleSearch').addEventListener('input', (e) => {
634
- state.console.searchFilter = e.target.value.toLowerCase().trim();
635
- renderConsole();
636
- });
637
-
638
- // Level dropdown toggle
639
- $('consoleLevelBtn').addEventListener('click', (e) => {
640
- e.stopPropagation();
641
- $('consoleLevelMenu').classList.toggle('open');
642
- });
643
-
644
- // Close dropdown when clicking outside
645
- document.addEventListener('click', (e) => {
646
- if (!e.target.closest('#consoleLevelDropdown')) {
647
- $('consoleLevelMenu')?.classList.remove('open');
648
- }
649
- });
650
-
651
- // Level checkbox changes
652
- $('consoleLevelMenu').addEventListener('change', (e) => {
653
- const checkbox = e.target;
654
- const level = checkbox.dataset.level;
655
- if (level) {
656
- state.console.levelFilters[level] = checkbox.checked;
657
- if (level === 'redux') state.console.showRedux = checkbox.checked;
658
- setStoredLogLevels(state.console.levelFilters);
659
- updateLevelBtnText();
660
- renderConsole();
661
- }
662
- });
663
-
664
- updateLevelBtnText();
665
-
666
- $('consoleExport')?.addEventListener('click', () => {
667
- const data = JSON.stringify(state.console.logs, null, 2);
668
- const blob = new Blob([data], { type: 'application/json' });
669
- const url = URL.createObjectURL(blob);
670
- const a = document.createElement('a');
671
- a.href = url; a.download = `reactoradar-console-${Date.now()}.json`; a.click();
672
- URL.revokeObjectURL(url);
673
- });
674
-
675
- $('consoleClear').addEventListener('click', () => {
676
- state.console.logs = [];
677
- _consolePending = [];
678
- _lastLogMsg = ''; _lastLogRow = null; _lastLogCount = 1;
679
- $('cBadge').textContent = '0';
680
- renderConsole();
681
- });
682
-
683
- // Find bar (Cmd+F)
684
- let _findMatches = [];
685
- let _findIdx = -1;
686
-
687
- function doFind(term) {
688
- // Clear previous highlights
689
- document.querySelectorAll('.console-find-highlight').forEach(el => {
690
- el.replaceWith(el.textContent);
691
- });
692
- _findMatches = [];
693
- _findIdx = -1;
694
- if (!term) { $('consoleFindCount').textContent = ''; return; }
695
-
696
- const rows = document.querySelectorAll('#consoleList .log-row');
697
- rows.forEach(row => {
698
- const text = row.textContent.toLowerCase();
699
- if (text.includes(term.toLowerCase())) _findMatches.push(row);
700
- });
701
- $('consoleFindCount').textContent = _findMatches.length ? `${_findMatches.length} found` : 'No matches';
702
- if (_findMatches.length) { _findIdx = 0; _findMatches[0].scrollIntoView({ block: 'nearest' }); _findMatches[0].style.outline = '1px solid var(--accent)'; }
703
- }
704
-
705
- function findNav(dir) {
706
- if (!_findMatches.length) return;
707
- if (_findMatches[_findIdx]) _findMatches[_findIdx].style.outline = '';
708
- _findIdx = (_findIdx + dir + _findMatches.length) % _findMatches.length;
709
- _findMatches[_findIdx].scrollIntoView({ block: 'nearest' });
710
- _findMatches[_findIdx].style.outline = '1px solid var(--accent)';
711
- $('consoleFindCount').textContent = `${_findIdx + 1}/${_findMatches.length}`;
712
- }
713
-
714
- $('consoleFindInput').addEventListener('input', (e) => doFind(e.target.value));
715
- $('consoleFindPrev').addEventListener('click', () => findNav(-1));
716
- $('consoleFindNext').addEventListener('click', () => findNav(1));
717
- $('consoleFindClose').addEventListener('click', () => {
718
- $('consoleFindBar').style.display = 'none';
719
- if (_findMatches[_findIdx]) _findMatches[_findIdx].style.outline = '';
720
- _findMatches = []; _findIdx = -1;
721
- $('consoleFindInput').value = '';
722
- $('consoleFindCount').textContent = '';
723
- });
724
- $('consoleFindInput').addEventListener('keydown', (e) => {
725
- if (e.key === 'Escape') $('consoleFindClose').click();
726
- if (e.key === 'Enter') findNav(e.shiftKey ? -1 : 1);
727
- });
728
- }
729
-
730
- function updateLevelBtnText() {
731
- const levels = state.console.levelFilters;
732
- const logLevels = { log: levels.log, info: levels.info, warn: levels.warn, error: levels.error, debug: levels.debug };
733
- const allOn = Object.values(logLevels).every(v => v);
734
- const allOff = Object.values(logLevels).every(v => !v);
735
- const btn = $('consoleLevelBtn');
330
+ function takeScreenshot() {
331
+ const btn = $('btnScreenshot');
736
332
  if (!btn) return;
737
- let text = '';
738
- if (allOn) text = 'All Levels';
739
- else if (allOff) text = 'None';
740
- else text = Object.entries(logLevels).filter(([, v]) => v).map(([k]) => k.charAt(0).toUpperCase() + k.slice(1)).join(', ');
741
- if (levels.redux) text += (text ? ' + ' : '') + 'Redux';
742
- btn.textContent = text + ' ▾';
743
- }
744
-
745
- // Console is fed via IPC (network-event handled in IPC section above)
746
-
747
- // ─── Toast Notifications ─────────────────────────────────────────────────────
748
- let _toastContainer = null;
749
- const _activeToasts = {};
750
-
751
- function getToastsEnabled() {
752
- try { return localStorage.getItem('rn-debug-toasts') !== 'false'; } catch { return true; }
753
- }
754
- function setToastsEnabled(v) {
755
- try { localStorage.setItem('rn-debug-toasts', v ? 'true' : 'false'); } catch {}
756
- }
757
-
758
- function showToast(message, type, targetPanel) {
759
- if (!getToastsEnabled()) return;
760
- if (!_toastContainer) {
761
- _toastContainer = document.createElement('div');
762
- _toastContainer.id = 'toastContainer';
763
- _toastContainer.className = 'toast-container';
764
- document.body.appendChild(_toastContainer);
765
- }
766
- // Don't show toast if user is already on the target panel
767
- if (targetPanel && state.activePanel === targetPanel) return;
768
-
769
- // Deduplicate: if same message already showing, increment count
770
- const key = `${type}:${message}`;
771
- if (_activeToasts[key] && _activeToasts[key].el.parentNode) {
772
- const existing = _activeToasts[key];
773
- existing.count++;
774
- const msgEl = existing.el.querySelector('.toast-msg');
775
- if (msgEl) msgEl.textContent = `${message} (${existing.count})`;
776
- // Reset auto-remove timer
777
- clearTimeout(existing.timer);
778
- existing.timer = setTimeout(() => {
779
- if (existing.el.parentNode) existing.el.remove();
780
- delete _activeToasts[key];
781
- }, 5000);
782
- return;
783
- }
784
-
785
- const toast = document.createElement('div');
786
- toast.className = `toast toast-${type || 'info'}`;
787
- toast.innerHTML = `<span class="toast-msg">${esc(message)}</span>`;
788
- if (targetPanel) {
789
- const btn = document.createElement('span');
790
- btn.className = 'toast-action';
791
- btn.textContent = 'View';
792
- btn.addEventListener('click', () => { switchPanel(targetPanel); toast.remove(); delete _activeToasts[key]; });
793
- toast.appendChild(btn);
794
- }
795
- const close = document.createElement('span');
796
- close.className = 'toast-close';
797
- close.textContent = '✕';
798
- close.addEventListener('click', () => { toast.remove(); delete _activeToasts[key]; });
799
- toast.appendChild(close);
800
-
801
- _toastContainer.appendChild(toast);
802
- const timer = setTimeout(() => {
803
- if (toast.parentNode) toast.remove();
804
- delete _activeToasts[key];
805
- }, 5000);
806
- _activeToasts[key] = { el: toast, count: 1, timer };
807
- // Keep max 3 toasts
808
- const toasts = _toastContainer.querySelectorAll('.toast');
809
- if (toasts.length > 3) { toasts[0].remove(); }
810
- }
811
-
812
- // ─── Batched console append (fixes re-render performance) ────────────────────
813
- let _consolePending = [];
814
- let _consoleRAF = null;
815
-
816
- let _lastLogMsg = '';
817
- let _lastLogRow = null;
818
- let _lastLogCount = 1;
819
-
820
- const MAX_CONSOLE_LOGS = 5000;
821
-
822
- function addConsoleLog(event) {
823
- state.console.logs.push(event);
824
- // Cap in-memory logs to prevent memory leak
825
- if (state.console.logs.length > MAX_CONSOLE_LOGS) {
826
- state.console.logs = state.console.logs.slice(-MAX_CONSOLE_LOGS);
827
- }
828
- _consolePending.push(event);
829
-
830
- // Batch DOM updates via rAF — only one paint per frame
831
- if (!_consoleRAF) {
832
- _consoleRAF = requestAnimationFrame(flushConsoleBatch);
833
- }
834
- }
835
-
836
- function flushConsoleBatch() {
837
- _consoleRAF = null;
838
- const batch = _consolePending;
839
- _consolePending = [];
840
- if (!batch.length) return;
841
-
842
- $('cBadge').textContent = state.console.logs.length;
843
-
844
- const list = $('consoleList');
845
- const empty = $('consoleEmpty');
846
- if (!list) return;
847
-
848
- const { levelFilters, searchFilter } = state.console;
849
- const frag = document.createDocumentFragment();
850
- let added = 0;
851
-
852
- batch.forEach(l => {
853
- // Redux logs use showRedux flag; regular logs use levelFilters
854
- if (l.level === 'redux') {
855
- if (!state.console.showRedux) return;
856
- } else if (levelFilters && !levelFilters[l.level]) return;
857
- if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return;
858
-
859
- // Group consecutive identical messages
860
- const msgKey = `${l.level}:${l.message || ''}`;
861
- if (msgKey === _lastLogMsg && _lastLogRow && _lastLogRow.parentNode) {
862
- _lastLogCount++;
863
- let badge = _lastLogRow.querySelector('.log-group-badge');
864
- if (!badge) {
865
- badge = document.createElement('span');
866
- badge.className = 'log-group-badge';
867
- _lastLogRow.insertBefore(badge, _lastLogRow.firstChild);
868
- }
869
- badge.textContent = _lastLogCount;
870
- return; // Don't add a new row
871
- }
872
-
873
- _lastLogMsg = msgKey;
874
- _lastLogCount = 1;
875
- const row = buildLogRow(l);
876
- _lastLogRow = row;
877
- frag.appendChild(row);
878
- added++;
879
- });
880
-
881
- if (added > 0) {
882
- // Hide empty state as soon as we have visible rows
883
- if (empty) empty.style.display = 'none';
884
- // Auto-scroll only if user is already near the bottom (within 150px)
885
- const wasAtBottom = (list.scrollHeight - list.scrollTop - list.clientHeight) < 150;
886
- list.appendChild(frag);
887
- // Keep DOM size manageable — remove oldest rows
888
- const rows = list.querySelectorAll('.log-row');
889
- const MAX_DOM_ROWS = 2000;
890
- if (rows.length > MAX_DOM_ROWS) {
891
- const toRemove = rows.length - MAX_DOM_ROWS;
892
- for (let i = 0; i < toRemove; i++) rows[i].remove();
893
- }
894
- if (wasAtBottom) list.scrollTop = list.scrollHeight;
895
- }
896
- }
897
-
898
- window.electronAPI?.on('console-event', addConsoleLog);
899
-
900
- // ─── Object Tree Renderer (Chrome DevTools-like) ─────────────────────────────
901
- // Builds interactive, collapsible DOM nodes for objects/arrays.
902
-
903
- // Collect all entries for an object: own data properties + prototype getter values.
904
- // Getter-derived keys use the clean name (e.g. "deliveryId") and skip backing
905
- // fields (e.g. "_deliveryId") so the log output mirrors the model's public API.
906
- function collectEntries(val) {
907
- if (Array.isArray(val)) return val.map((v, i) => [i, v]);
908
-
909
- const result = {};
910
- const getterKeys = new Set();
911
-
912
- // 1. Walk prototype chain and invoke getters
913
- let proto = Object.getPrototypeOf(val);
914
- while (proto && proto !== Object.prototype) {
915
- const descs = Object.getOwnPropertyDescriptors(proto);
916
- for (const [k, desc] of Object.entries(descs)) {
917
- if (k === 'constructor') continue;
918
- if (desc.get && !(k in result)) {
919
- try { result[k] = desc.get.call(val); } catch { /* skip broken getters */ }
920
- getterKeys.add(k);
921
- }
922
- }
923
- proto = Object.getPrototypeOf(proto);
924
- }
925
-
926
- // 2. Add own data properties, but skip backing fields whose getter is present.
927
- // Convention: getter "foo" backs "_foo"; if "foo" was collected, skip "_foo".
928
- const ownKeys = Object.keys(val);
929
- for (const k of ownKeys) {
930
- const clean = k.startsWith('_') ? k.slice(1) : null;
931
- if (clean && getterKeys.has(clean)) continue; // skip _backing field
932
- if (!(k in result)) result[k] = val[k];
933
- }
934
-
935
- return Object.entries(result);
936
- }
937
-
938
- function objPreview(val, maxLen) {
939
- maxLen = maxLen || 80;
940
- if (val === null) return 'null';
941
- if (val === undefined) return 'undefined';
942
- if (Array.isArray(val)) {
943
- if (val.length === 0) return '[]';
944
- const items = [];
945
- let len = 2; // [ ]
946
- for (let i = 0; i < val.length && len < maxLen; i++) {
947
- const s = primitivePreview(val[i]);
948
- len += s.length + 2;
949
- items.push(s);
950
- }
951
- const suffix = items.length < val.length ? ', ...' : '';
952
- return `(${val.length}) [${items.join(', ')}${suffix}]`;
953
- }
954
- if (typeof val === 'object') {
955
- const entries = collectEntries(val);
956
- if (entries.length === 0) return '{}';
957
- const items = [];
958
- let len = 2;
959
- for (let i = 0; i < entries.length && len < maxLen; i++) {
960
- const s = `${entries[i][0]}: ${primitivePreview(entries[i][1])}`;
961
- len += s.length + 2;
962
- items.push(s);
963
- }
964
- const suffix = items.length < entries.length ? ', ...' : '';
965
- return `{${items.join(', ')}${suffix}}`;
966
- }
967
- return primitivePreview(val);
968
- }
969
-
970
- function primitivePreview(val) {
971
- if (val === null) return 'null';
972
- if (val === undefined) return 'undefined';
973
- if (typeof val === 'string') return val.length > 50 ? `"${val.slice(0,50)}..."` : `"${val}"`;
974
- if (typeof val === 'number' || typeof val === 'boolean') return String(val);
975
- if (Array.isArray(val)) return `Array(${val.length})`;
976
- if (typeof val === 'object') return `{...}`;
977
- return String(val);
978
- }
979
-
980
- function createTreeNode(key, val, startCollapsed) {
981
- const isArray = Array.isArray(val);
982
- const isObj = val !== null && typeof val === 'object';
983
-
984
- if (!isObj) {
985
- // Primitive leaf
986
- const row = document.createElement('div');
987
- row.className = 'ov-leaf';
988
- if (key !== null) {
989
- const k = document.createElement('span');
990
- k.className = 'ov-key';
991
- k.textContent = isNaN(key) ? `${key}: ` : `${key}: `;
992
- row.appendChild(k);
993
- }
994
- row.appendChild(createPrimitiveSpan(val));
995
- return row;
996
- }
997
-
998
- // Collapsible object/array
999
- const container = document.createElement('div');
1000
- container.className = 'ov-node';
1001
-
1002
- const header = document.createElement('div');
1003
- header.className = 'ov-header';
1004
-
1005
- const arrow = document.createElement('span');
1006
- arrow.className = 'ov-arrow';
1007
- arrow.textContent = '\u25B6'; // ▶
1008
- header.appendChild(arrow);
1009
-
1010
- if (key !== null) {
1011
- const k = document.createElement('span');
1012
- k.className = 'ov-key';
1013
- k.textContent = `${key}: `;
1014
- header.appendChild(k);
1015
- }
1016
-
1017
- const preview = document.createElement('span');
1018
- preview.className = 'ov-preview';
1019
- preview.textContent = objPreview(val);
1020
- header.appendChild(preview);
1021
-
1022
- container.appendChild(header);
1023
-
1024
- const children = document.createElement('div');
1025
- children.className = 'ov-children';
1026
- children.style.display = 'none';
1027
-
1028
- let populated = false;
1029
-
1030
- function populateChildren() {
1031
- if (populated) return;
1032
- populated = true;
1033
- const entries = collectEntries(val);
1034
- entries.forEach(([k, v]) => {
1035
- children.appendChild(createTreeNode(k, v, true));
1036
- });
1037
- // For arrays show length, for objects show prototype hint
1038
- if (isArray) {
1039
- const lenNode = document.createElement('div');
1040
- lenNode.className = 'ov-leaf ov-meta';
1041
- lenNode.textContent = `length: ${val.length}`;
1042
- children.appendChild(lenNode);
1043
- }
1044
- }
1045
-
1046
- let expanded = !startCollapsed;
1047
- if (expanded) {
1048
- populateChildren();
1049
- children.style.display = 'block';
1050
- arrow.textContent = '\u25BC'; // ▼
1051
- arrow.classList.add('expanded');
1052
- preview.style.display = 'none';
1053
- }
1054
-
1055
- header.addEventListener('click', (e) => {
1056
- e.stopPropagation();
1057
- expanded = !expanded;
1058
- if (expanded) {
1059
- populateChildren();
1060
- children.style.display = 'block';
1061
- arrow.textContent = '\u25BC';
1062
- arrow.classList.add('expanded');
1063
- preview.style.display = 'none';
1064
- } else {
1065
- children.style.display = 'none';
1066
- arrow.textContent = '\u25B6';
1067
- arrow.classList.remove('expanded');
1068
- preview.style.display = '';
1069
- }
1070
- });
1071
-
1072
- container.appendChild(children);
1073
- return container;
1074
- }
1075
-
1076
- function _safeStr(val) {
1077
- if (val === null) return 'null';
1078
- if (val === undefined) return 'undefined';
1079
- if (typeof val === 'string') return val;
1080
- if (typeof val === 'number' || typeof val === 'boolean') return String(val);
1081
- try { return JSON.stringify(val, null, 2); } catch { return String(val); }
1082
- }
1083
-
1084
- function createPrimitiveSpan(val) {
1085
- const s = document.createElement('span');
1086
- if (val === null) { s.className = 'ov-null'; s.textContent = 'null'; }
1087
- else if (val === undefined) { s.className = 'ov-undef'; s.textContent = 'undefined'; }
1088
- else if (typeof val === 'string') { s.className = 'ov-str'; s.textContent = `"${val}"`; }
1089
- else if (typeof val === 'number') { s.className = 'ov-num'; s.textContent = String(val); }
1090
- else if (typeof val === 'boolean') { s.className = 'ov-bool'; s.textContent = String(val); }
1091
- else { s.textContent = _safeStr(val); }
1092
- return s;
1093
- }
1094
-
1095
- // Parse a structured arg from the SDK (or fall back to raw message string)
1096
- function renderConsoleArg(arg) {
1097
- if (!arg || typeof arg !== 'object' || !arg.t) {
1098
- // Backward compat: raw string
1099
- const s = document.createElement('span');
1100
- s.className = 'ov-str';
1101
- s.textContent = _safeStr(arg);
1102
- return s;
1103
- }
1104
- const { t, v } = arg;
1105
- if (t === 'string') {
1106
- const s = document.createElement('span');
1107
- s.className = 'log-text';
1108
- s.textContent = v;
1109
- return s;
1110
- }
1111
- if (t === 'number') { return createPrimitiveSpan(v); }
1112
- if (t === 'boolean') { return createPrimitiveSpan(v); }
1113
- if (t === 'null') { return createPrimitiveSpan(null); }
1114
- if (t === 'undefined') { return createPrimitiveSpan(undefined); }
1115
- if (t === 'object' || t === 'array') {
1116
- return createTreeNode(null, v, false);
1117
- }
1118
- const s = document.createElement('span');
1119
- s.textContent = _safeStr(v);
1120
- return s;
1121
- }
1122
-
1123
- // Build the body of a console log row. If structured args exist, render each;
1124
- // otherwise fall back to the flat message string and try to detect JSON in it.
1125
- function buildLogBody(logEntry) {
1126
- const container = document.createElement('div');
1127
- container.className = 'log-body';
1128
-
1129
- if (logEntry.args && Array.isArray(logEntry.args) && logEntry.args.length > 0) {
1130
- // Structured args from updated SDK
1131
- logEntry.args.forEach((arg, i) => {
1132
- if (i > 0) container.appendChild(document.createTextNode(' '));
1133
- container.appendChild(renderConsoleArg(arg));
1134
- });
1135
- } else if (logEntry.message != null) {
1136
- // Legacy / flat message — try to parse JSON objects out of it
1137
- const msg = String(logEntry.message);
1138
- // Try parsing the whole message as JSON
1139
- try {
1140
- const parsed = JSON.parse(msg);
1141
- if (typeof parsed === 'object' && parsed !== null) {
1142
- container.appendChild(createTreeNode(null, parsed, false));
1143
- return container;
1144
- }
1145
- } catch {}
1146
-
1147
- // Otherwise render as text, but look for embedded JSON blocks
1148
- // If it looks like it contains JSON, try to pretty-render inline
1149
- const jsonRe = /(\{[\s\S]*\}|\[[\s\S]*\])/;
1150
- const match = msg.match(jsonRe);
1151
- if (match && match[0].length > 2) {
1152
- try {
1153
- const parsed = JSON.parse(match[0]);
1154
- // There's text before/after
1155
- const before = msg.slice(0, match.index);
1156
- const after = msg.slice(match.index + match[0].length);
1157
- if (before) container.appendChild(document.createTextNode(before));
1158
- container.appendChild(createTreeNode(null, parsed, false));
1159
- if (after) container.appendChild(document.createTextNode(after));
1160
- return container;
1161
- } catch {}
1162
- }
1163
-
1164
- // Plain text
1165
- const span = document.createElement('span');
1166
- span.className = 'log-text';
1167
- span.textContent = msg;
1168
- container.appendChild(span);
1169
- }
1170
-
1171
- return container;
1172
- }
1173
-
1174
- function buildLogRow(l) {
1175
- const div = document.createElement('div');
1176
- div.className = `log-row entry ${l.level}`;
1177
-
1178
- const timeSpan = document.createElement('span');
1179
- timeSpan.className = 'log-time';
1180
- timeSpan.textContent = ts(l.ts);
1181
- div.appendChild(timeSpan);
1182
-
1183
- const lvlSpan = document.createElement('span');
1184
- lvlSpan.className = `lvl-badge lvl-${l.level}`;
1185
- lvlSpan.textContent = l.level;
1186
- div.appendChild(lvlSpan);
1187
-
1188
- // Arrow (inline, not inside body-wrap)
1189
- const arrow = document.createElement('span');
1190
- arrow.className = 'log-arrow';
1191
- arrow.textContent = '\u25B6';
1192
- div.appendChild(arrow);
1193
-
1194
- // Body wrapper
1195
- const bodyWrap = document.createElement('div');
1196
- bodyWrap.className = 'log-body-wrap';
1197
-
1198
- // Single-line preview: message text + caller
1199
- const preview = document.createElement('div');
1200
- preview.className = 'log-preview';
1201
- const msgText = (l.message || '').replace(/\n/g, ' ').slice(0, 200);
1202
- const previewText = document.createElement('span');
1203
- previewText.textContent = msgText + ((l.message || '').length > 200 ? '...' : '');
1204
- preview.appendChild(previewText);
1205
- bodyWrap.appendChild(preview);
1206
-
1207
- // Full content (hidden by default)
1208
- const full = document.createElement('div');
1209
- full.className = 'log-full';
1210
- full.style.display = 'none';
1211
- full.appendChild(buildLogBody(l));
1212
- bodyWrap.appendChild(full);
1213
-
1214
- let expanded = false;
1215
- // Only toggle on click, NOT on text selection drag
1216
- let _mouseDownPos = null;
1217
- bodyWrap.addEventListener('mousedown', (e) => {
1218
- _mouseDownPos = { x: e.clientX, y: e.clientY };
1219
- });
1220
- bodyWrap.addEventListener('click', (e) => {
1221
- // Don't toggle if user is selecting text (dragged mouse)
1222
- if (_mouseDownPos) {
1223
- const dx = Math.abs(e.clientX - _mouseDownPos.x);
1224
- const dy = Math.abs(e.clientY - _mouseDownPos.y);
1225
- if (dx > 3 || dy > 3) return; // user dragged to select
1226
- }
1227
- // Don't toggle if there's an active text selection
1228
- const sel = window.getSelection();
1229
- if (sel && sel.toString().length > 0) return;
1230
- // Don't toggle if clicking inside object tree expander
1231
- if (e.target.closest('.ov-header')) return;
1232
- expanded = !expanded;
1233
- if (expanded) {
1234
- preview.style.display = 'none';
1235
- full.style.display = 'block';
1236
- arrow.textContent = '\u25BC';
1237
- arrow.classList.add('expanded');
1238
- } else {
1239
- preview.style.display = '';
1240
- full.style.display = 'none';
1241
- arrow.textContent = '\u25B6';
1242
- arrow.classList.remove('expanded');
1243
- }
1244
- });
1245
-
1246
- // Right-click → copy options
1247
- div.addEventListener('contextmenu', (e) => {
1248
- e.preventDefault();
1249
- const items = [];
1250
-
1251
- // Copy selected text
1252
- const sel = window.getSelection();
1253
- if (sel && sel.toString().length > 0) {
1254
- items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
1255
- }
1256
-
1257
- // Copy full log message
1258
- items.push({ label: 'Copy Message', action: () => {
1259
- navigator.clipboard.writeText(l.message || '');
1260
- }});
1261
-
1262
- // Copy as JSON (if structured args exist)
1263
- if (l.args && l.args.length > 0) {
1264
- items.push({ label: 'Copy as JSON', action: () => {
1265
- const json = l.args.map(a => {
1266
- if (a.t === 'object' || a.t === 'array') return JSON.stringify(a.v, null, 2);
1267
- return String(a.v);
1268
- }).join(' ');
1269
- navigator.clipboard.writeText(json);
1270
- }});
1271
- }
1272
-
1273
- // Copy caller location
1274
- if (l.caller) {
1275
- items.push({ label: 'Copy Caller', action: () => navigator.clipboard.writeText(l.caller) });
1276
- }
1277
-
1278
- showContextMenu(e, items);
1279
- });
1280
-
1281
- div.appendChild(bodyWrap);
1282
- return div;
1283
- }
1284
-
1285
- // ─── Shared context menu helper ──────────────────────────────────────────────
1286
- function showContextMenu(e, items) {
1287
- document.querySelectorAll('.ctx-menu').forEach(el => el.remove());
1288
- const menu = document.createElement('div');
1289
- menu.className = 'ctx-menu';
1290
- items.forEach(({ label, action }) => {
1291
- if (label === '—' || !action) {
1292
- const sep = document.createElement('div');
1293
- sep.className = 'ctx-sep';
1294
- menu.appendChild(sep);
1295
- return;
1296
- }
1297
- const item = document.createElement('div');
1298
- item.className = 'ctx-item';
1299
- item.textContent = label;
1300
- item.addEventListener('click', () => { action(); menu.remove(); });
1301
- menu.appendChild(item);
1302
- });
1303
- menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
1304
- menu.style.top = Math.min(e.clientY, window.innerHeight - items.length * 32 - 10) + 'px';
1305
- document.body.appendChild(menu);
1306
- setTimeout(() => {
1307
- const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close); } };
1308
- document.addEventListener('click', close);
1309
- }, 0);
1310
- }
1311
-
1312
- // Full re-render — only used on filter/level change, NOT on every incoming log
1313
- function renderConsole() {
1314
- const list = $('consoleList');
1315
- const empty = $('consoleEmpty');
1316
- if (!list) return;
1317
-
1318
- const { levelFilters, searchFilter } = state.console;
1319
- const visible = state.console.logs.filter(l => {
1320
- // Redux logs use showRedux flag; regular logs use levelFilters
1321
- if (l.level === 'redux') {
1322
- if (!state.console.showRedux) return false;
1323
- } else if (levelFilters && !levelFilters[l.level]) return false;
1324
- if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return false;
1325
- return true;
1326
- });
1327
-
1328
- list.querySelectorAll('.log-row').forEach(e => e.remove());
1329
- if (!empty) { /* guard */ }
1330
- else if (visible.length > 0) {
1331
- empty.style.display = 'none';
1332
- } else if (state.console.logs.length > 0) {
1333
- const lbl = empty.querySelector('.label');
1334
- const hint = empty.querySelector('.hint');
1335
- if (lbl) lbl.textContent = 'No matching logs';
1336
- if (hint) hint.textContent = 'Adjust level filters or clear search to see logs';
1337
- empty.style.display = 'flex';
1338
- } else {
1339
- const lbl = empty.querySelector('.label');
1340
- const hint = empty.querySelector('.hint');
1341
- if (lbl) lbl.textContent = 'No logs yet';
1342
- if (hint) hint.textContent = 'Logs will appear here automatically';
1343
- empty.style.display = 'flex';
1344
- }
1345
-
1346
- // Render only the last N visible rows for performance
1347
- const MAX_RENDER = 5000;
1348
- const toRender = visible.length > MAX_RENDER ? visible.slice(-MAX_RENDER) : visible;
1349
- if (visible.length > MAX_RENDER) {
1350
- const info = document.createElement('div');
1351
- info.className = 'log-row';
1352
- info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;text-align:center;font-style:italic';
1353
- info.textContent = `${visible.length - MAX_RENDER} older logs hidden for performance`;
1354
- list.appendChild(info);
1355
- }
1356
-
1357
- const frag = document.createDocumentFragment();
1358
- toRender.forEach(l => frag.appendChild(buildLogRow(l)));
1359
- list.appendChild(frag);
1360
- list.scrollTop = list.scrollHeight;
1361
- }
1362
-
1363
- // ─────────────────────────────────────────────────────────────────────────────
1364
- // NETWORK PANEL (Chrome DevTools-style)
1365
- // ─────────────────────────────────────────────────────────────────────────────
1366
- const NET_COLS = [
1367
- { key: 'name', label: 'Name', width: 380, min: 150 },
1368
- { key: 'status', label: 'Status', width: 60, min: 40 },
1369
- { key: 'type', label: 'Type', width: 70, min: 40 },
1370
- { key: 'initiator', label: 'Initiator', width: 80, min: 50 },
1371
- { key: 'size', label: 'Size', width: 65, min: 40 },
1372
- { key: 'time', label: 'Time', width: 65, min: 40 },
1373
- { key: 'waterfall', label: 'Waterfall', width: 100, min: 60 },
1374
- ];
1375
-
1376
- function initNetworkPanel() {
1377
- const panel = $('panel-network');
1378
- panel.innerHTML = `
1379
- <div class="panel-toolbar">
1380
- <span class="panel-label">Network</span>
1381
- <span class="badge" id="nBadge">0</span>
1382
- <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
1383
- <button class="panel-clear-btn" id="networkExport" title="Export as HAR">Export HAR</button>
1384
- <button class="panel-clear-btn" id="networkClear" title="Clear network">Clear</button>
1385
- <label class="toggle-label" for="netToggle">
1386
- <span class="toggle-text" id="netToggleText">Capture ON</span>
1387
- <input type="checkbox" id="netToggle" class="toggle-input" checked />
1388
- <span class="toggle-slider"></span>
1389
- </label>
1390
- </div>
1391
- </div>
1392
- <div class="net-filter-bar" id="netFilterBar">
1393
- <input id="netSearchInput" class="net-search-input" placeholder="Filter URLs..." />
1394
- <div class="net-type-filters" id="netTypeFilters">
1395
- <button class="net-type-btn" data-type="all">All</button>
1396
- <button class="net-type-btn active" data-type="fetch">Fetch/XHR</button>
1397
- <button class="net-type-btn" data-type="js">JS</button>
1398
- <button class="net-type-btn" data-type="css">CSS</button>
1399
- <button class="net-type-btn" data-type="img">Img</button>
1400
- <button class="net-type-btn" data-type="media">Media</button>
1401
- <button class="net-type-btn" data-type="font">Font</button>
1402
- <button class="net-type-btn" data-type="doc">Doc</button>
1403
- <button class="net-type-btn" data-type="ws">WS</button>
1404
- </div>
1405
- <div class="net-status-filters" id="netStatusFilters">
1406
- <button class="net-status-btn active" data-status="all">All</button>
1407
- <button class="net-status-btn" data-status="2xx">2xx</button>
1408
- <button class="net-status-btn" data-status="errors">Errors</button>
1409
- <button class="net-status-btn net-slow-btn" data-status="slow">Slow (>1s)</button>
1410
- </div>
1411
- <div class="net-hidden-wrap" style="position:relative;margin-left:4px">
1412
- <button class="net-status-btn net-hidden-btn" id="netHiddenBtn" style="display:none" title="Manage hidden URLs">Hidden</button>
1413
- <div class="net-hidden-dropdown" id="netHiddenDropdown" style="display:none"></div>
1414
- </div>
1415
- <div class="net-throttle" id="netThrottle">
1416
- <select id="netThrottleSelect" class="net-throttle-select">
1417
- <option value="none">No throttling</option>
1418
- <option value="fast3g">Fast 3G</option>
1419
- <option value="slow3g">Slow 3G</option>
1420
- <option value="offline">Offline</option>
1421
- </select>
1422
- </div>
1423
- </div>
1424
- <div class="net-layout">
1425
- <div class="net-table-wrap" id="netTableWrap">
1426
- <div class="net-header" id="netHeader"></div>
1427
- <div class="net-rows" id="netRows">
1428
- <div class="empty-state" id="networkEmpty">
1429
- <div class="icon">📡</div>
1430
- <div class="label">No requests yet</div>
1431
- <div class="hint">API calls will appear here automatically</div>
1432
- </div>
1433
- </div>
1434
- </div>
1435
- <div class="net-detail-pane" id="netDetailPane">
1436
- <div class="net-detail-bar">
1437
- <div class="detail-tabs" id="netDetailTabs"></div>
1438
- <div class="detail-search-wrap" id="detailSearchWrap" style="display:none">
1439
- <input id="detailSearchInput" class="detail-search-input" placeholder="Search key or value..." />
1440
- <span id="detailSearchCount" class="detail-search-count"></span>
1441
- <button class="detail-search-nav" id="detailSearchPrev" title="Previous">&#9650;</button>
1442
- <button class="detail-search-nav" id="detailSearchNext" title="Next">&#9660;</button>
1443
- <button class="detail-search-close" id="detailSearchClose" title="Close search">&times;</button>
1444
- </div>
1445
- <button class="detail-close" id="netDetailClose" title="Close">&times;</button>
1446
- </div>
1447
- <div class="detail-content" id="netDetailContent"></div>
1448
- </div>
1449
- </div>
1450
- <div class="net-stats-bar" id="netStatsBar">
1451
- <span id="netStatsTotal">0 requests</span>
1452
- <span class="net-stats-sep">|</span>
1453
- <span id="netStatsAvg">Avg: —</span>
1454
- <span class="net-stats-sep">|</span>
1455
- <span id="netStatsSlowest">Slowest: —</span>
1456
- <span class="net-stats-sep">|</span>
1457
- <span id="netStatsErrors">Errors: 0</span>
1458
- <span class="net-stats-sep">|</span>
1459
- <span id="netStatsSlow">Slow (>1s): 0</span>
1460
- </div>`;
1461
-
1462
- $('netToggle').addEventListener('change', (e) => {
1463
- state.network.enabled = e.target.checked;
1464
- $('netToggleText').textContent = e.target.checked ? 'Capture ON' : 'Capture OFF';
1465
- window.electronAPI?.setNetworkCapture(e.target.checked);
1466
- });
1467
-
1468
- // Network search input
1469
- $('netSearchInput').addEventListener('input', (e) => {
1470
- state.network.searchFilter = e.target.value.toLowerCase().trim();
1471
- renderNetwork();
1472
- });
1473
-
1474
- // Type filter buttons
1475
- $('netTypeFilters').addEventListener('click', (e) => {
1476
- const btn = e.target.closest('.net-type-btn');
1477
- if (!btn) return;
1478
- $('netTypeFilters').querySelectorAll('.net-type-btn').forEach(b => b.classList.remove('active'));
1479
- btn.classList.add('active');
1480
- state.network.typeFilter = btn.dataset.type;
1481
- renderNetwork();
1482
- });
1483
-
1484
- // Status filter buttons (All / 2xx / Errors / Slow)
1485
- $('netStatusFilters').addEventListener('click', (e) => {
1486
- const btn = e.target.closest('.net-status-btn');
1487
- if (!btn) return;
1488
- $('netStatusFilters').querySelectorAll('.net-status-btn').forEach(b => b.classList.remove('active'));
1489
- btn.classList.add('active');
1490
- state.network.statusFilter = btn.dataset.status;
1491
- renderNetwork();
1492
- });
1493
-
1494
- // Hidden URLs button
1495
- $('netHiddenBtn')?.addEventListener('click', () => {
1496
- const dd = $('netHiddenDropdown');
1497
- if (!dd) return;
1498
- const isOpen = dd.style.display !== 'none';
1499
- if (isOpen) { dd.style.display = 'none'; return; }
1500
- // Build dropdown with hidden URL list
1501
- const hidden = getHiddenURLs();
1502
- dd.innerHTML = '';
1503
- if (!hidden.length) { dd.style.display = 'none'; return; }
1504
- const title = document.createElement('div');
1505
- title.className = 'net-hidden-title';
1506
- title.innerHTML = `<span>Hidden URLs (${hidden.length})</span><button class="net-hidden-clear" id="netHiddenClearAll">Clear All</button>`;
1507
- dd.appendChild(title);
1508
- hidden.forEach(pattern => {
1509
- const row = document.createElement('div');
1510
- row.className = 'net-hidden-row';
1511
- const label = document.createElement('span');
1512
- label.className = 'net-hidden-url';
1513
- label.textContent = pattern;
1514
- label.title = pattern;
1515
- row.appendChild(label);
1516
- const btn = document.createElement('button');
1517
- btn.className = 'net-hidden-unhide';
1518
- btn.textContent = 'Unhide';
1519
- btn.addEventListener('click', () => {
1520
- removeHiddenURL(pattern);
1521
- row.remove();
1522
- renderNetwork();
1523
- if (!getHiddenURLs().length) dd.style.display = 'none';
1524
- });
1525
- row.appendChild(btn);
1526
- dd.appendChild(row);
1527
- });
1528
- dd.style.display = 'block';
1529
- // Clear all handler
1530
- dd.querySelector('#netHiddenClearAll')?.addEventListener('click', () => {
1531
- setHiddenURLs([]);
1532
- _updateHiddenBadge();
1533
- dd.style.display = 'none';
1534
- renderNetwork();
1535
- });
1536
- });
1537
- // Close dropdown when clicking outside
1538
- document.addEventListener('click', (e) => {
1539
- const dd = $('netHiddenDropdown');
1540
- if (dd && dd.style.display !== 'none' && !e.target.closest('.net-hidden-wrap')) {
1541
- dd.style.display = 'none';
1542
- }
1543
- });
1544
- // Initialize hidden badge
1545
- _updateHiddenBadge();
1546
-
1547
- // Throttle select
1548
- $('netThrottleSelect').addEventListener('change', (e) => {
1549
- state.network.throttle = e.target.value;
1550
- // Send throttle config to the RN app
1551
- window.electronAPI?.setNetworkThrottle(state.network.throttle);
1552
- });
1553
-
1554
- // Export network as HAR
1555
- $('networkExport')?.addEventListener('click', () => {
1556
- const entries = state.network.order.map(id => {
1557
- const r = state.network.requests[id];
1558
- if (!r) return null;
1559
- return {
1560
- startedDateTime: new Date(r.ts || Date.now()).toISOString(),
1561
- time: r.duration || 0,
1562
- request: {
1563
- method: r.method || 'GET',
1564
- url: r.url || '',
1565
- headers: Object.entries(r.requestHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
1566
- postData: r.requestBody ? { mimeType: 'application/json', text: typeof r.requestBody === 'object' ? JSON.stringify(r.requestBody) : String(r.requestBody) } : undefined,
1567
- },
1568
- response: {
1569
- status: r.status || 0,
1570
- statusText: r.statusText || '',
1571
- headers: Object.entries(r.responseHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
1572
- content: { size: -1, mimeType: 'application/json', text: r.responseBody ? (typeof r.responseBody === 'object' ? JSON.stringify(r.responseBody) : String(r.responseBody)) : '' },
1573
- },
1574
- timings: { send: 0, wait: r.duration || 0, receive: 0 },
1575
- };
1576
- }).filter(Boolean);
1577
- const har = { log: { version: '1.2', creator: { name: 'ReactoRadar', version: '1.6.0' }, entries } };
1578
- const blob = new Blob([JSON.stringify(har, null, 2)], { type: 'application/json' });
1579
- const url = URL.createObjectURL(blob);
1580
- const a = document.createElement('a');
1581
- a.href = url; a.download = `reactoradar-network-${Date.now()}.har`; a.click();
1582
- URL.revokeObjectURL(url);
1583
- });
1584
-
1585
- // Clear network
1586
- $('networkClear').addEventListener('click', () => {
1587
- state.network.requests = {};
1588
- state.network.order = [];
1589
- state.network.selectedId = null;
1590
- closeNetDetail();
1591
- $('nBadge').textContent = '0';
1592
- renderNetwork();
1593
- });
1594
-
1595
- // Close detail button
1596
- $('netDetailClose').addEventListener('click', closeNetDetail);
1597
-
1598
- // Detail panel search
1599
- let _detailSearchMatches = [];
1600
- let _detailSearchIdx = -1;
1601
-
1602
- function _detailSearch() {
1603
- const term = $('detailSearchInput')?.value?.trim().toLowerCase();
1604
- const body = $('netDetailContent');
1605
- if (!body || !term) { _detailClearSearch(); return; }
1606
-
1607
- // Remove old highlights
1608
- body.querySelectorAll('.detail-search-hl').forEach(el => {
1609
- const parent = el.parentNode;
1610
- parent.replaceChild(document.createTextNode(el.textContent), el);
1611
- parent.normalize();
1612
- });
1613
-
1614
- _detailSearchMatches = [];
1615
- _detailSearchIdx = -1;
1616
-
1617
- // Walk all text nodes and highlight matches
1618
- const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null);
1619
- const textNodes = [];
1620
- while (walker.nextNode()) textNodes.push(walker.currentNode);
1621
-
1622
- textNodes.forEach(node => {
1623
- const text = node.textContent;
1624
- const lower = text.toLowerCase();
1625
- if (!lower.includes(term)) return;
1626
-
1627
- const frag = document.createDocumentFragment();
1628
- let lastIdx = 0;
1629
- let idx;
1630
- while ((idx = lower.indexOf(term, lastIdx)) !== -1) {
1631
- if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
1632
- const hl = document.createElement('span');
1633
- hl.className = 'detail-search-hl';
1634
- hl.textContent = text.slice(idx, idx + term.length);
1635
- _detailSearchMatches.push(hl);
1636
- frag.appendChild(hl);
1637
- lastIdx = idx + term.length;
1638
- }
1639
- if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
1640
- node.parentNode.replaceChild(frag, node);
1641
- });
1642
-
1643
- // Update count
1644
- const countEl = $('detailSearchCount');
1645
- if (countEl) countEl.textContent = _detailSearchMatches.length ? `${_detailSearchMatches.length} found` : 'No match';
1646
-
1647
- // Navigate to first match
1648
- if (_detailSearchMatches.length) _detailNavTo(0);
1649
- }
1650
-
1651
- function _detailNavTo(idx) {
1652
- // Remove active highlight from previous
1653
- if (_detailSearchIdx >= 0 && _detailSearchMatches[_detailSearchIdx]) {
1654
- _detailSearchMatches[_detailSearchIdx].classList.remove('active');
1655
- }
1656
- _detailSearchIdx = idx;
1657
- const el = _detailSearchMatches[idx];
1658
- if (!el) return;
1659
- el.classList.add('active');
1660
- el.scrollIntoView({ block: 'center', behavior: 'smooth' });
1661
- // Update count
1662
- const countEl = $('detailSearchCount');
1663
- if (countEl) countEl.textContent = `${idx + 1}/${_detailSearchMatches.length}`;
1664
- }
1665
-
1666
- function _detailClearSearch() {
1667
- const body = $('netDetailContent');
1668
- if (body) {
1669
- body.querySelectorAll('.detail-search-hl').forEach(el => {
1670
- const parent = el.parentNode;
1671
- parent.replaceChild(document.createTextNode(el.textContent), el);
1672
- parent.normalize();
1673
- });
1674
- }
1675
- _detailSearchMatches = [];
1676
- _detailSearchIdx = -1;
1677
- const countEl = $('detailSearchCount');
1678
- if (countEl) countEl.textContent = '';
1679
- }
1680
-
1681
- $('detailSearchInput')?.addEventListener('input', () => {
1682
- clearTimeout($('detailSearchInput')._debounce);
1683
- $('detailSearchInput')._debounce = setTimeout(_detailSearch, 200);
1684
- });
1685
- $('detailSearchInput')?.addEventListener('keydown', (e) => {
1686
- if (e.key === 'Enter') {
1687
- e.preventDefault();
1688
- if (!_detailSearchMatches.length) return;
1689
- const next = e.shiftKey
1690
- ? (_detailSearchIdx - 1 + _detailSearchMatches.length) % _detailSearchMatches.length
1691
- : (_detailSearchIdx + 1) % _detailSearchMatches.length;
1692
- _detailNavTo(next);
1693
- }
1694
- if (e.key === 'Escape') {
1695
- _detailClearSearch();
1696
- $('detailSearchWrap').style.display = 'none';
1697
- }
1698
- });
1699
- $('detailSearchNext')?.addEventListener('click', () => {
1700
- if (!_detailSearchMatches.length) return;
1701
- _detailNavTo((_detailSearchIdx + 1) % _detailSearchMatches.length);
1702
- });
1703
- $('detailSearchPrev')?.addEventListener('click', () => {
1704
- if (!_detailSearchMatches.length) return;
1705
- _detailNavTo((_detailSearchIdx - 1 + _detailSearchMatches.length) % _detailSearchMatches.length);
1706
- });
1707
- $('detailSearchClose')?.addEventListener('click', () => {
1708
- _detailClearSearch();
1709
- $('detailSearchInput').value = '';
1710
- $('detailSearchWrap').style.display = 'none';
1711
- });
1712
-
1713
- buildNetHeader();
1714
- }
1715
-
1716
- // ─── Column header with sort icons + full-height resize handles ──────────────
1717
- function buildNetHeader() {
1718
- const header = $('netHeader');
1719
- header.innerHTML = '';
1720
- NET_COLS.forEach((col, i) => {
1721
- const cell = document.createElement('div');
1722
- cell.className = 'net-hcell';
1723
- cell.style.width = col.width + 'px';
1724
- cell.dataset.col = col.key;
1725
-
1726
- const label = document.createElement('span');
1727
- label.className = 'net-hcell-label';
1728
- label.textContent = col.label;
1729
- cell.appendChild(label);
1730
-
1731
- if (col.key !== 'waterfall') {
1732
- const sortIcon = document.createElement('span');
1733
- sortIcon.className = 'net-sort-icon';
1734
- if (state.network.sortCol === col.key) {
1735
- sortIcon.textContent = state.network.sortDir === 'asc' ? ' \u25B2' : ' \u25BC';
1736
- sortIcon.classList.add('active');
1737
- }
1738
- cell.appendChild(sortIcon);
1739
- cell.addEventListener('click', (e) => {
1740
- if (e.target.closest('.net-hcell-resize')) return;
1741
- if (state.network.sortCol === col.key) {
1742
- state.network.sortDir = state.network.sortDir === 'asc' ? 'desc' : 'asc';
1743
- } else {
1744
- state.network.sortCol = col.key;
1745
- state.network.sortDir = col.key === 'name' ? 'asc' : 'desc';
1746
- }
1747
- buildNetHeader();
1748
- renderNetwork();
1749
- });
1750
- cell.style.cursor = 'pointer';
1751
- }
1752
-
1753
- // Resize handle in header
1754
- if (i < NET_COLS.length - 1) {
1755
- const handle = document.createElement('div');
1756
- handle.className = 'net-hcell-resize';
1757
- handle.addEventListener('mousedown', (e) => startColResize(e, col));
1758
- cell.appendChild(handle);
1759
- }
1760
- header.appendChild(cell);
1761
- });
1762
-
1763
- // Build full-height resize overlay lines
1764
- buildResizeOverlays();
1765
- }
1766
-
1767
- function buildResizeOverlays() {
1768
- // Remove old overlays
1769
- document.querySelectorAll('.net-resize-overlay').forEach(e => e.remove());
1770
- const tableWrap = $('netTableWrap');
1771
- if (!tableWrap) return;
1772
- // Make the table wrap position:relative for overlay positioning
1773
- tableWrap.style.position = 'relative';
1774
-
1775
- let leftOffset = 0;
1776
- NET_COLS.forEach((col, i) => {
1777
- leftOffset += col.width;
1778
- if (i >= NET_COLS.length - 1) return; // no handle after last column
1779
-
1780
- const overlay = document.createElement('div');
1781
- overlay.className = 'net-resize-overlay';
1782
- overlay.style.left = (leftOffset - 3) + 'px';
1783
- overlay.addEventListener('mousedown', (e) => startColResize(e, col));
1784
- tableWrap.appendChild(overlay);
1785
- });
1786
- }
1787
-
1788
- function startColResize(e, col) {
1789
- e.preventDefault();
1790
- e.stopPropagation();
1791
- const startX = e.clientX;
1792
- const startW = col.width;
1793
-
1794
- // Add visual feedback
1795
- document.body.style.cursor = 'col-resize';
1796
- document.body.style.userSelect = 'none';
1797
-
1798
- function onMove(ev) {
1799
- const delta = ev.clientX - startX;
1800
- col.width = Math.max(col.min, startW + delta);
1801
- // Update header + all data cells for this column
1802
- document.querySelectorAll(`.net-cell[data-col="${col.key}"], .net-hcell[data-col="${col.key}"]`)
1803
- .forEach(el => el.style.width = col.width + 'px');
1804
- // Keep detail pane aligned with Name column
1805
- if (col.key === 'name' && state.network.selectedId) {
1806
- const pane = $('netDetailPane');
1807
- if (pane) pane.style.left = (col.width + 1) + 'px';
1808
- }
1809
- // Reposition overlays
1810
- buildResizeOverlays();
1811
- }
1812
- function onUp() {
1813
- document.body.style.cursor = '';
1814
- document.body.style.userSelect = '';
1815
- document.removeEventListener('mousemove', onMove);
1816
- document.removeEventListener('mouseup', onUp);
1817
- }
1818
- document.addEventListener('mousemove', onMove);
1819
- document.addEventListener('mouseup', onUp);
1820
- }
1821
-
1822
- // ─── Network type matching ──────────────────────────────────────────────────
1823
- function matchNetType(r, type) {
1824
- const ct = (r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '').toLowerCase();
1825
- const url = (r.url || '').toLowerCase();
1826
- switch (type) {
1827
- case 'fetch': // Fetch/XHR — show API calls (JSON, text, form data), exclude static assets
1828
- return !ct.includes('image') && !ct.includes('font') && !ct.includes('video') && !ct.includes('audio')
1829
- && !/\.(png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp4|mp3|css)(\?|$)/.test(url);
1830
- case 'js': return ct.includes('javascript') || /\.(js|jsx|bundle)(\?|$)/.test(url);
1831
- case 'css': return ct.includes('css') || /\.css(\?|$)/.test(url);
1832
- case 'img': return ct.includes('image') || /\.(png|jpg|jpeg|gif|svg|webp|ico|avif|bmp)(\?|$)/.test(url);
1833
- case 'media': return ct.includes('video') || ct.includes('audio') || /\.(mp4|mp3|wav|webm|ogg|m3u8)(\?|$)/.test(url);
1834
- case 'font': return ct.includes('font') || /\.(woff2?|ttf|otf|eot)(\?|$)/.test(url);
1835
- case 'doc': return ct.includes('html') || ct.includes('xml') || /\.(html?|xml)(\?|$)/.test(url);
1836
- case 'ws': return url.startsWith('ws://') || url.startsWith('wss://');
1837
- default: return true;
1838
- }
333
+ const origText = btn.innerHTML;
334
+ btn.innerHTML = '<span style="opacity:0.6">Saving...</span>';
335
+ window.electronAPI?.captureScreenshot();
336
+ btn.innerHTML = '<span style="color:var(--green)">Saved!</span>';
337
+ setTimeout(() => { btn.innerHTML = origText; }, 2000);
1839
338
  }
1840
-
1841
- let _netRAF = null;
1842
-
1843
- function handleNetworkEvent(event) {
1844
- if (event.type === 'console') { addConsoleLog(event); return; }
1845
- if (event.type !== 'network') return;
1846
- if (!state.network.enabled) return;
1847
-
1848
- const { id, phase } = event;
1849
- if (phase === 'request') {
1850
- state.network.requests[id] = { ...event, _tab: 'headers' };
1851
- if (!state.network.order.includes(id)) state.network.order.push(id);
1852
- // Cap network history to prevent memory leak
1853
- const MAX_NET_HISTORY = 1000;
1854
- if (state.network.order.length > MAX_NET_HISTORY) {
1855
- const trimIds = state.network.order.splice(0, state.network.order.length - MAX_NET_HISTORY);
1856
- trimIds.forEach(tid => delete state.network.requests[tid]);
1857
- }
1858
- $('nBadge').textContent = state.network.order.length;
1859
- } else {
1860
- Object.assign(state.network.requests[id] || (state.network.requests[id] = {}), event);
1861
- // Toast for errors and slow APIs
1862
- const r = state.network.requests[id];
1863
- if (r && (phase === 'response' || phase === 'error')) {
1864
- const name = r.url?.split('/').pop()?.split('?')[0] || r.url || '?';
1865
- if (r.phase === 'error' || (r.status && r.status >= 400)) {
1866
- showToast(`API Error: ${r.status || 'ERR'} ${name}`, 'error', 'network');
1867
- } else if ((r.duration || 0) >= 3000) {
1868
- showToast(`Slow API: ${(r.duration/1000).toFixed(1)}s — ${name}`, 'warn', 'network');
1869
- }
1870
- }
1871
- }
1872
- if (!_netRAF) {
1873
- _netRAF = requestAnimationFrame(() => {
1874
- _netRAF = null;
1875
- renderNetwork();
1876
- });
1877
- }
1878
- }
1879
-
1880
- // ─── Sort network IDs ───────────────────────────────────────────────────────
1881
- function sortNetworkIds(ids) {
1882
- const { sortCol, sortDir } = state.network;
1883
- const reqs = state.network.requests;
1884
- const sorted = [...ids].sort((a, b) => {
1885
- const ra = reqs[a], rb = reqs[b];
1886
- if (!ra || !rb) return 0;
1887
- let va, vb;
1888
- switch (sortCol) {
1889
- case 'name':
1890
- va = (ra.url || '').toLowerCase(); vb = (rb.url || '').toLowerCase();
1891
- return va < vb ? -1 : va > vb ? 1 : 0;
1892
- case 'status':
1893
- va = ra.status || 0; vb = rb.status || 0;
1894
- return va - vb;
1895
- case 'type':
1896
- va = (ra.responseHeaders?.['content-type'] || '').toLowerCase();
1897
- vb = (rb.responseHeaders?.['content-type'] || '').toLowerCase();
1898
- return va < vb ? -1 : va > vb ? 1 : 0;
1899
- case 'size':
1900
- // Use cached size or estimate — avoid JSON.stringify in sort comparator
1901
- va = ra._cachedSize ?? (ra._cachedSize = typeof ra.responseBody === 'string' ? ra.responseBody.length : (ra.responseBody != null ? 100 : 0));
1902
- vb = rb._cachedSize ?? (rb._cachedSize = typeof rb.responseBody === 'string' ? rb.responseBody.length : (rb.responseBody != null ? 100 : 0));
1903
- return va - vb;
1904
- case 'time':
1905
- default:
1906
- va = ra.ts || 0; vb = rb.ts || 0;
1907
- return va - vb;
1908
- }
1909
- });
1910
- if (sortDir === 'desc') sorted.reverse();
1911
- return sorted;
1912
- }
1913
-
1914
- // ─── Render network rows ────────────────────────────────────────────────────
1915
- function renderNetwork() {
1916
- const rows = $('netRows');
1917
- const empty = $('networkEmpty');
1918
- if (!rows) return;
1919
-
1920
- const { statusFilter, typeFilter, searchFilter } = state.network;
1921
- const visible = state.network.order.filter(id => {
1922
- const r = state.network.requests[id];
1923
- if (!r) return false;
1924
- if (statusFilter === '2xx' && !(r.status >= 200 && r.status < 300)) return false;
1925
- if (statusFilter === 'errors' && !(r.phase === 'error' || r.status >= 400)) return false;
1926
- if (statusFilter === 'slow' && !((r.duration || 0) >= 1000)) return false;
1927
- if (searchFilter && !r.url?.toLowerCase().includes(searchFilter)) return false;
1928
- if (typeFilter !== 'all' && !matchNetType(r, typeFilter)) return false;
1929
- if (isURLHidden(r.url || '')) return false;
1930
- return true;
1931
- });
1932
-
1933
- // Sort: apply current sort, default = newest first
1934
- const sortedVisible = sortNetworkIds(visible);
1935
-
1936
- empty.style.display = sortedVisible.length ? 'none' : 'flex';
1937
- rows.querySelectorAll('.net-row').forEach(e => e.remove());
1938
-
1939
- // Waterfall scale: find min/max timestamps
1940
- let wfMin = Infinity, wfMax = 0;
1941
- sortedVisible.forEach(id => {
1942
- const r = state.network.requests[id];
1943
- if (r.ts) { wfMin = Math.min(wfMin, r.ts); wfMax = Math.max(wfMax, r.ts + (r.duration || 0)); }
1944
- });
1945
- const wfRange = Math.max(wfMax - wfMin, 1);
1946
-
1947
- // Render max 300 rows for performance
1948
- const MAX_NET_ROWS = 300;
1949
- const toRender = sortedVisible.length > MAX_NET_ROWS ? sortedVisible.slice(0, MAX_NET_ROWS) : sortedVisible;
1950
-
1951
- const frag = document.createDocumentFragment();
1952
- if (sortedVisible.length > MAX_NET_ROWS) {
1953
- const info = document.createElement('div');
1954
- info.className = 'net-row';
1955
- info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;justify-content:center;font-style:italic';
1956
- info.textContent = `Showing ${MAX_NET_ROWS} of ${sortedVisible.length} requests`;
1957
- frag.appendChild(info);
1958
- }
1959
- toRender.forEach(id => {
1960
- const r = state.network.requests[id];
1961
- frag.appendChild(buildNetRow(r, wfMin, wfRange));
1962
- });
1963
- rows.appendChild(frag);
1964
- _updateNetStats();
1965
- }
1966
-
1967
- function _updateNetStats() {
1968
- const allReqs = state.network.order.map(id => state.network.requests[id]).filter(Boolean);
1969
- const completed = allReqs.filter(r => r.duration != null);
1970
- const total = allReqs.length;
1971
- const errors = allReqs.filter(r => r.phase === 'error' || (r.status && r.status >= 400)).length;
1972
- const slow = completed.filter(r => r.duration >= 1000).length;
1973
- const durations = completed.map(r => r.duration);
1974
- const avg = durations.length ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
1975
- const slowest = durations.length ? Math.max(...durations) : 0;
1976
- const slowestReq = completed.find(r => r.duration === slowest);
1977
- const slowestName = slowestReq ? (tryURL(slowestReq.url)?.pathname?.split('/').pop() || slowestReq.url?.split('/').pop() || '?') : '—';
1978
-
1979
- const el = (id, text) => { const e = $(id); if (e) e.textContent = text; };
1980
- el('netStatsTotal', `${total} requests`);
1981
- el('netStatsAvg', `Avg: ${avg ? (avg > 999 ? `${(avg/1000).toFixed(1)}s` : `${avg}ms`) : '—'}`);
1982
- el('netStatsSlowest', `Slowest: ${slowest ? (slowest > 999 ? `${(slowest/1000).toFixed(1)}s` : `${slowest}ms`) + ` (${slowestName})` : '—'}`);
1983
- el('netStatsErrors', `Errors: ${errors}`);
1984
- el('netStatsSlow', `Slow (>1s): ${slow}`);
1985
- // Highlight if there are slow or errored requests
1986
- if (slow > 0) $('netStatsSlow')?.classList.add('warn');
1987
- else $('netStatsSlow')?.classList.remove('warn');
1988
- if (errors > 0) $('netStatsErrors')?.classList.add('err');
1989
- else $('netStatsErrors')?.classList.remove('err');
1990
- }
1991
-
1992
- function _isHttpError(r) {
1993
- return r.phase === 'error' || (r.status && r.status >= 400);
1994
- }
1995
-
1996
- function buildNetRow(r, wfMin, wfRange) {
1997
- const row = document.createElement('div');
1998
- const rowSlow = !_isHttpError(r) && (r.duration || 0) >= 1000;
1999
- const rowVerySlow = !_isHttpError(r) && (r.duration || 0) >= 3000;
2000
- row.className = 'net-row' + (r.id === state.network.selectedId ? ' selected' : '') + (_isHttpError(r) ? ' error' : '') + (rowVerySlow ? ' very-slow' : rowSlow ? ' slow' : '');
2001
- row.dataset.id = r.id;
2002
-
2003
- const urlObj = tryURL(r.url);
2004
- const pathname = urlObj ? urlObj.pathname : r.url || '';
2005
- const filename = pathname.split('/').filter(Boolean).pop() || pathname;
2006
- const host = urlObj ? urlObj.host : '';
2007
-
2008
- // Name — show method + full path (expands with column)
2009
- const nameCell = document.createElement('div');
2010
- nameCell.className = 'net-cell net-cell-name';
2011
- nameCell.dataset.col = 'name';
2012
- nameCell.style.width = NET_COLS[0].width + 'px';
2013
- const method = r.method || '?';
2014
- const mClass = ['GET','POST','PUT','PATCH','DELETE'].includes(method) ? `m-${method}` : 'm-other';
2015
- const fullPath = urlObj ? urlObj.pathname + urlObj.search : r.url || '';
2016
- const isErr = _isHttpError(r);
2017
- const pathCls = isErr ? ' net-path-error' : '';
2018
- nameCell.innerHTML = `<span class="method-badge ${mClass}">${method}</span> <span class="net-path${pathCls}" title="${esc(r.url)}">${esc(fullPath)}</span><span class="net-host">${esc(host)}</span>`;
2019
- row.appendChild(nameCell);
2020
-
2021
- // Status
2022
- const statusCell = document.createElement('div');
2023
- statusCell.className = 'net-cell net-status';
2024
- statusCell.dataset.col = 'status';
2025
- statusCell.style.width = NET_COLS[1].width + 'px';
2026
- let statusStr = '...', sCls = 's-pending';
2027
- if (r.phase === 'error') { statusStr = 'ERR'; sCls = 's-err'; }
2028
- else if (r.status) {
2029
- statusStr = String(r.status);
2030
- const group = Math.floor(r.status / 100);
2031
- // 1xx info, 2xx success, 3xx redirect, 4xx client error, 5xx server error
2032
- if (group >= 4) sCls = 's-err';
2033
- else sCls = `s-${group}`;
2034
- }
2035
- statusCell.className += ` ${sCls}`;
2036
- statusCell.textContent = statusStr;
2037
- row.appendChild(statusCell);
2038
-
2039
- // Type (content-type from response headers)
2040
- const typeCell = document.createElement('div');
2041
- typeCell.className = 'net-cell net-type';
2042
- typeCell.dataset.col = 'type';
2043
- typeCell.style.width = NET_COLS[2].width + 'px';
2044
- const ct = r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '';
2045
- typeCell.textContent = ct.split(';')[0].replace('application/', '').replace('text/', '') || '—';
2046
- row.appendChild(typeCell);
2047
-
2048
- // Initiator
2049
- const initCell = document.createElement('div');
2050
- initCell.className = 'net-cell net-initiator';
2051
- initCell.dataset.col = 'initiator';
2052
- initCell.style.width = NET_COLS[3].width + 'px';
2053
- initCell.textContent = r.initiator || 'xhr';
2054
- row.appendChild(initCell);
2055
-
2056
- // Size
2057
- const sizeCell = document.createElement('div');
2058
- sizeCell.className = 'net-cell net-size';
2059
- sizeCell.dataset.col = 'size';
2060
- sizeCell.style.width = NET_COLS[4].width + 'px';
2061
- const bodyStr = typeof r.responseBody === 'string' ? r.responseBody : (r.responseBody != null ? JSON.stringify(r.responseBody) : '');
2062
- sizeCell.textContent = bodyStr.length > 0 ? formatSize(bodyStr.length) : '—';
2063
- row.appendChild(sizeCell);
2064
-
2065
- // Time
2066
- const timeCell = document.createElement('div');
2067
- const dur = r.duration || 0;
2068
- const slowClass = dur >= 3000 ? ' very-slow' : dur >= 1000 ? ' slow' : '';
2069
- timeCell.className = 'net-cell net-time' + slowClass;
2070
- timeCell.dataset.col = 'time';
2071
- timeCell.style.width = NET_COLS[5].width + 'px';
2072
- timeCell.textContent = r.duration != null ? (r.duration > 999 ? `${(r.duration/1000).toFixed(1)}s` : `${r.duration}ms`) : '...';
2073
- row.appendChild(timeCell);
2074
-
2075
- // Waterfall
2076
- const wfCell = document.createElement('div');
2077
- wfCell.className = 'net-cell net-waterfall';
2078
- wfCell.dataset.col = 'waterfall';
2079
- wfCell.style.width = NET_COLS[6].width + 'px';
2080
- if (r.ts) {
2081
- const left = ((r.ts - wfMin) / wfRange) * 100;
2082
- const width = Math.max(2, ((r.duration || 50) / wfRange) * 100);
2083
- let barCls = 'pending';
2084
- if (r.phase === 'error') barCls = 'err';
2085
- else if (r.status && r.status >= 400) barCls = 'err';
2086
- else if (r.status) barCls = `s${Math.floor(r.status/100)}`;
2087
- wfCell.innerHTML = `<div class="wf-bar ${barCls}" style="left:${left}%;width:${width}%"></div>`;
2088
- }
2089
- row.appendChild(wfCell);
2090
-
2091
- // Click to select and show detail
2092
- row.addEventListener('click', () => selectNetRequest(r.id));
2093
-
2094
- // Right-click for context menu (copy as cURL)
2095
- row.addEventListener('contextmenu', (e) => {
2096
- e.preventDefault();
2097
- showNetContextMenu(e, r);
2098
- });
2099
-
2100
- return row;
2101
- }
2102
-
2103
- // ─── Select request → overlay detail pane over Status/Type/etc columns ───────
2104
- function selectNetRequest(id) {
2105
- state.network.selectedId = id;
2106
- const r = state.network.requests[id];
2107
- if (!r) return;
2108
-
2109
- // Highlight selected row
2110
- document.querySelectorAll('#netRows .net-row').forEach(el =>
2111
- el.classList.toggle('selected', el.dataset.id === id)
2112
- );
2113
-
2114
- // Position detail pane to overlay everything after the Name column
2115
- const pane = $('netDetailPane');
2116
- const nameColWidth = NET_COLS[0].width;
2117
- pane.style.left = (nameColWidth + 1) + 'px'; // +1 for the border
2118
- pane.classList.add('open');
2119
- r._tab = r._tab || 'headers';
2120
- renderNetDetailTabs(r);
2121
- renderNetDetailContent(r);
2122
- }
2123
-
2124
- function closeNetDetail() {
2125
- state.network.selectedId = null;
2126
- const pane = $('netDetailPane');
2127
- if (pane) pane.classList.remove('open');
2128
- document.querySelectorAll('#netRows .net-row').forEach(el =>
2129
- el.classList.remove('selected')
2130
- );
2131
- }
2132
-
2133
- function _estimateSize(val) {
2134
- if (val == null) return 0;
2135
- if (typeof val === 'string') return val.length;
2136
- try { return JSON.stringify(val).length; } catch { return 0; }
2137
- }
2138
-
2139
- function _formatBytes(bytes) {
2140
- if (bytes < 1024) return `${bytes}B`;
2141
- if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
2142
- return `${(bytes / 1048576).toFixed(1)}MB`;
2143
- }
2144
-
2145
- function renderNetDetailTabs(r) {
2146
- const tabs = $('netDetailTabs');
2147
- tabs.innerHTML = '';
2148
-
2149
- const tabDefs = [
2150
- { label: 'Headers', key: 'headers' },
2151
- { label: 'Request', key: 'request', sizeFrom: 'requestBody' },
2152
- { label: 'Preview', key: 'preview', sizeFrom: 'responseBody' },
2153
- { label: 'Response', key: 'response', sizeFrom: 'responseBody' },
2154
- ];
2155
-
2156
- tabDefs.forEach(({ label, key, sizeFrom }) => {
2157
- const btn = document.createElement('button');
2158
- btn.className = 'detail-tab' + (r._tab === key ? ' active' : '');
2159
- let text = label;
2160
- if (sizeFrom && r[sizeFrom]) {
2161
- const size = _estimateSize(r[sizeFrom]);
2162
- if (size > 0) text += ` (${_formatBytes(size)})`;
2163
- }
2164
- btn.textContent = text;
2165
- btn.addEventListener('click', () => {
2166
- r._tab = key;
2167
- tabs.querySelectorAll('.detail-tab').forEach(b => b.classList.remove('active'));
2168
- btn.classList.add('active');
2169
- renderNetDetailContent(r);
2170
- });
2171
- tabs.appendChild(btn);
2172
- });
2173
-
2174
- // Show search box for Preview/Response tabs
2175
- const searchWrap = $('detailSearchWrap');
2176
- if (searchWrap) {
2177
- searchWrap.style.display = (r._tab === 'preview' || r._tab === 'response' || r._tab === 'headers') ? 'flex' : 'none';
2178
- }
2179
- }
2180
-
2181
- function renderNetDetailContent(r) {
2182
- let body = $('netDetailContent');
2183
- if (!body) return;
2184
- // Clone-replace to remove all stale event listeners (prevents contextmenu leak)
2185
- const fresh = body.cloneNode(false);
2186
- body.parentNode.replaceChild(fresh, body);
2187
- body = fresh;
2188
- const tab = r._tab || 'headers';
2189
-
2190
- if (tab === 'headers') {
2191
- const rqH = r.requestHeaders || {};
2192
- const rsH = r.responseHeaders || {};
2193
- const renderH = (title, h) => {
2194
- const keys = Object.keys(h);
2195
- if (!keys.length) return `<div class="section-label">${title}</div><span style="color:var(--text-dim)">none</span>`;
2196
- return `<div class="section-label">${title}</div><div class="kv-grid">${keys.map(k => {
2197
- let val = h[k];
2198
- if (val && typeof val === 'object') { try { val = JSON.stringify(val); } catch { val = String(val); } }
2199
- return `<span class="kv-key">${esc(k)}</span><span class="kv-val">${esc(val)}</span>`;
2200
- }).join('')}</div>`;
2201
- };
2202
- body.innerHTML = `<div class="section-label" style="margin-top:0">General</div>
2203
- <div class="kv-grid">
2204
- <span class="kv-key">Request URL</span><span class="kv-val">${esc(r.url)}</span>
2205
- <span class="kv-key">Method</span><span class="kv-val">${esc(r.method)}</span>
2206
- <span class="kv-key">Status</span><span class="kv-val ${r.phase === 'error' ? 's-err' : r.status ? (r.status >= 400 ? 's-err' : 's-' + Math.floor(r.status/100)) : 's-pending'}">${r.phase === 'error' ? (r.status || 'ERR') : (r.status || 'Pending')} ${r.statusText || (r.phase === 'error' ? r.error || 'Network Error' : '')}</span>
2207
- </div>
2208
- ${renderH('Response Headers', rsH)}
2209
- ${renderH('Request Headers', rqH)}`;
2210
- } else if (tab === 'request') {
2211
- if (!r.requestBody) {
2212
- body.innerHTML = '<span style="color:var(--text-dim)">No request body</span>';
2213
- } else {
2214
- body.innerHTML = '';
2215
- let reqData = r.requestBody;
2216
- if (typeof reqData === 'string') {
2217
- try { reqData = JSON.parse(reqData); } catch {}
2218
- }
2219
- if (reqData && typeof reqData === 'object') {
2220
- body.appendChild(createTreeNode(null, reqData, false));
2221
- body.addEventListener('contextmenu', (e) => {
2222
- e.preventDefault();
2223
- showPreviewCopyMenu(e, reqData);
2224
- });
2225
- } else {
2226
- body.innerHTML = renderJSON(r.requestBody);
2227
- }
2228
- }
2229
- } else if (tab === 'preview') {
2230
- const isErrStatus = _isHttpError(r);
2231
- if (r.phase === 'error' && !r.responseBody) { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
2232
- if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
2233
- // Render as collapsible JSON tree with right-click copy
2234
- const val = r.responseBody;
2235
- let treeData = val;
2236
- if (typeof val === 'string') {
2237
- try { treeData = JSON.parse(val); } catch {
2238
- body.innerHTML = `<span style="color:${isErrStatus ? 'var(--red)' : 'inherit'}">${esc(val)}</span>`;
2239
- return;
2240
- }
2241
- }
2242
- if (treeData && typeof treeData === 'object') {
2243
- body.innerHTML = '';
2244
- // Show error status banner above the response body
2245
- if (isErrStatus) {
2246
- const errBanner = document.createElement('div');
2247
- errBanner.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0 8px;font-size:11px;border-bottom:1px solid rgba(255,94,114,.15);margin-bottom:8px';
2248
- errBanner.textContent = `${r.status || 'ERR'} ${r.statusText || r.error || 'Error'}`;
2249
- body.appendChild(errBanner);
2250
- }
2251
- body.appendChild(createTreeNode(null, treeData, false));
2252
- // Right-click on preview to copy the whole object or clicked node value
2253
- body.addEventListener('contextmenu', (e) => {
2254
- e.preventDefault();
2255
- showPreviewCopyMenu(e, treeData);
2256
- });
2257
- } else {
2258
- body.innerHTML = isErrStatus
2259
- ? `<span style="color:var(--red)">${esc(String(r.responseBody))}</span>`
2260
- : '<span style="color:var(--text-dim)">No preview available</span>';
2261
- }
2262
- } else if (tab === 'response') {
2263
- const isErrStatus = _isHttpError(r);
2264
- if (r.phase === 'error' && !r.responseBody) { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
2265
- if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
2266
- if (isErrStatus) {
2267
- const errBanner = document.createElement('div');
2268
- errBanner.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0 8px;font-size:11px;border-bottom:1px solid rgba(255,94,114,.15);margin-bottom:8px';
2269
- errBanner.textContent = `${r.status || 'ERR'} ${r.statusText || r.error || 'Error'}`;
2270
- body.innerHTML = '';
2271
- body.appendChild(errBanner);
2272
- const raw = document.createElement('div');
2273
- raw.style.color = 'var(--red)';
2274
- raw.innerHTML = renderJSON(r.responseBody);
2275
- body.appendChild(raw);
2276
- } else {
2277
- body.innerHTML = renderJSON(r.responseBody);
2278
- }
2279
- }
2280
- }
2281
-
2282
- // ─── Network context menus ──────────────────────────────────────────────────
2283
- function showNetContextMenu(e, r) {
2284
- const items = [
2285
- { label: 'Copy as cURL', action: () => navigator.clipboard.writeText(buildCurlCommand(r)) },
2286
- { label: 'Copy URL', action: () => navigator.clipboard.writeText(r.url || '') },
2287
- ];
2288
- if (r.responseBody) {
2289
- items.push({ label: 'Copy Response', action: () => {
2290
- const text = typeof r.responseBody === 'string' ? r.responseBody : JSON.stringify(r.responseBody, null, 2);
2291
- navigator.clipboard.writeText(text);
2292
- }});
2293
- }
2294
- // Hide URL option
2295
- items.push({ label: '—', action: null }); // separator
2296
- items.push({ label: 'Hide this URL', action: () => {
2297
- addHiddenURL(r.url || '');
2298
- renderNetwork();
2299
- }});
2300
- showContextMenu(e, items);
2301
- }
2302
-
2303
- function showPreviewCopyMenu(e, fullData) {
2304
- const items = [
2305
- { label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(fullData, null, 2)) },
2306
- ];
2307
- const sel = window.getSelection();
2308
- if (sel && sel.toString().length > 0) {
2309
- items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
2310
- }
2311
- const keyEl = e.target.closest('.ov-key');
2312
- const leafEl = e.target.closest('.ov-leaf');
2313
- if (keyEl || leafEl) {
2314
- items.push({ label: 'Copy Value', action: () => navigator.clipboard.writeText((leafEl || keyEl.parentElement).textContent) });
2315
- }
2316
- showContextMenu(e, items);
2317
- }
2318
-
2319
- function buildCurlCommand(r) {
2320
- let cmd = `curl '${r.url}'`;
2321
- if (r.method && r.method !== 'GET') cmd += ` -X ${r.method}`;
2322
- const headers = r.requestHeaders || {};
2323
- Object.entries(headers).forEach(([k, v]) => {
2324
- cmd += ` \\\n -H '${k}: ${v}'`;
2325
- });
2326
- if (r.requestBody) {
2327
- const body = typeof r.requestBody === 'string' ? r.requestBody : JSON.stringify(r.requestBody);
2328
- cmd += ` \\\n --data-raw '${body.replace(/'/g, "'\\''")}'`;
2329
- }
2330
- return cmd;
2331
- }
2332
-
2333
- // ─────────────────────────────────────────────────────────────────────────────
2334
- // GA4 EVENT INSPECTOR
2335
- // ─────────────────────────────────────────────────────────────────────────────
2336
- const ga4State = { events: [], selected: -1, searchFilter: '', sortDir: 'desc' };
2337
-
2338
- function initGA4Panel() {
2339
- const panel = $('panel-ga4');
2340
- panel.innerHTML = `
2341
- <div class="panel-toolbar">
2342
- <span class="panel-label">GA4 Events</span>
2343
- <span class="badge" id="ga4Badge">0</span>
2344
- <input id="ga4Search" class="net-search-input" style="margin-left:12px" placeholder="Filter events..." />
2345
- <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
2346
- <label class="toggle-label" for="ga4ColorToggle" style="font-size:10px;gap:4px">
2347
- <span style="color:var(--text-dim)">Colors</span>
2348
- <input type="checkbox" id="ga4ColorToggle" class="toggle-input" ${getGA4ColorsEnabled() ? 'checked' : ''} />
2349
- <span class="toggle-slider"></span>
2350
- </label>
2351
- <button class="panel-clear-btn" id="ga4Clear" title="Clear GA4 events">Clear</button>
2352
- </div>
2353
- </div>
2354
- <div class="ga4-layout">
2355
- <div class="ga4-list-pane">
2356
- <div class="ga4-list-header">
2357
- <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>
2358
- <span class="ga4-hcell" style="flex:1">Event</span>
2359
- </div>
2360
- <div class="scroll-area" id="ga4List">
2361
- <div class="empty-state" id="ga4Empty">
2362
- <div class="icon" style="font-size:28px;opacity:.2">📊</div>
2363
- <div class="label">No GA4 events yet</div>
2364
- <div class="hint">Events from @react-native-firebase/analytics will appear here</div>
2365
- </div>
2366
- </div>
2367
- </div>
2368
- <div class="ga4-resize-handle" id="ga4ResizeHandle"></div>
2369
- <div class="ga4-detail-pane" id="ga4DetailPane">
2370
- <div class="ga4-detail-header">EVENT DETAIL</div>
2371
- <div class="scroll-area ga4-detail-content" id="ga4Detail">
2372
- <span style="color:var(--text-dim);padding:16px;display:block">Click an event to inspect</span>
2373
- </div>
2374
- </div>
2375
- </div>
2376
- <div class="ga4-summary" id="ga4Summary">
2377
- <span class="ga4-summary-label">Total: 0</span>
2378
- </div>`;
2379
-
2380
- $('ga4Search').addEventListener('input', (e) => {
2381
- ga4State.searchFilter = e.target.value.toLowerCase().trim();
2382
- renderGA4List();
2383
- renderGA4Summary(); // update active chip highlight
2384
- });
2385
-
2386
- $('ga4ColorToggle')?.addEventListener('change', (e) => {
2387
- setGA4ColorsEnabled(e.target.checked);
2388
- renderGA4List();
2389
- renderGA4Summary();
2390
- });
2391
-
2392
- $('ga4Clear').addEventListener('click', () => {
2393
- ga4State.events = [];
2394
- ga4State.selected = -1;
2395
- ga4State.searchFilter = '';
2396
- const search = $('ga4Search');
2397
- if (search) search.value = '';
2398
- $('ga4Badge').textContent = '0';
2399
- renderGA4List();
2400
- renderGA4Summary();
2401
- // Clear detail pane
2402
- const detail = $('ga4Detail');
2403
- if (detail) detail.innerHTML = '<div class="ga4-detail-empty" style="color:var(--text-dim);padding:20px;text-align:center;font-size:11px">Select an event to view details</div>';
2404
- });
2405
-
2406
- $('ga4SortBtn').addEventListener('click', () => {
2407
- ga4State.sortDir = ga4State.sortDir === 'desc' ? 'asc' : 'desc';
2408
- $('ga4SortIcon').textContent = ga4State.sortDir === 'desc' ? '\u25BC' : '\u25B2';
2409
- renderGA4List();
2410
- });
2411
-
2412
- // Resizable divider between list and detail
2413
- const resizeHandle = $('ga4ResizeHandle');
2414
- const detailPane = $('ga4DetailPane');
2415
- resizeHandle.addEventListener('mousedown', (e) => {
2416
- e.preventDefault();
2417
- const startX = e.clientX;
2418
- const startWidth = detailPane.offsetWidth;
2419
- document.body.style.cursor = 'col-resize';
2420
- document.body.style.userSelect = 'none';
2421
- function onMove(ev) {
2422
- const delta = startX - ev.clientX;
2423
- detailPane.style.width = Math.max(200, Math.min(window.innerWidth * 0.8, startWidth + delta)) + 'px';
2424
- }
2425
- function onUp() {
2426
- document.body.style.cursor = '';
2427
- document.body.style.userSelect = '';
2428
- document.removeEventListener('mousemove', onMove);
2429
- document.removeEventListener('mouseup', onUp);
2430
- }
2431
- document.addEventListener('mousemove', onMove);
2432
- document.addEventListener('mouseup', onUp);
2433
- });
2434
- }
2435
-
2436
- function handleGA4Event(event) {
2437
- if (!isTabEnabled('ga4')) return;
2438
- ga4State.events.push({
2439
- name: event.name || '?',
2440
- params: event.params || {},
2441
- tag: event.tag || 'GA4',
2442
- source: event.source || '',
2443
- ts: event.ts || Date.now(),
2444
- index: ga4State.events.length,
2445
- });
2446
- $('ga4Badge').textContent = ga4State.events.length;
2447
-
2448
- // Append to list (batched via rAF)
2449
- if (!ga4State._raf) {
2450
- ga4State._raf = requestAnimationFrame(() => {
2451
- ga4State._raf = null;
2452
- renderGA4List();
2453
- renderGA4Summary();
2454
- });
2455
- }
2456
- }
2457
-
2458
- // Assign consistent color to each GA4 event name
2459
- const _ga4EventColors = {};
2460
- const _ga4ColorPalette = [
2461
- '#4facff', // blue
2462
- '#3dd68c', // green
2463
- '#ff813f', // orange
2464
- '#c678dd', // purple
2465
- '#e06c75', // coral
2466
- '#56b6c2', // teal
2467
- '#d19a66', // gold
2468
- '#98c379', // lime
2469
- '#e5c07b', // yellow
2470
- '#ff5e72', // red
2471
- '#61afef', // light blue
2472
- '#be5046', // rust
2473
- ];
2474
- let _ga4ColorIdx = 0;
2475
- function _ga4EventColor(name) {
2476
- if (!getGA4ColorsEnabled()) return ''; // empty = inherit default text color
2477
- if (!_ga4EventColors[name]) {
2478
- _ga4EventColors[name] = _ga4ColorPalette[_ga4ColorIdx % _ga4ColorPalette.length];
2479
- _ga4ColorIdx++;
2480
- }
2481
- return _ga4EventColors[name];
2482
- }
2483
- function getGA4ColorsEnabled() {
2484
- try { return localStorage.getItem('rn-debug-ga4-colors') === 'true'; } catch { return false; }
2485
- }
2486
- function setGA4ColorsEnabled(v) {
2487
- try { localStorage.setItem('rn-debug-ga4-colors', v ? 'true' : 'false'); } catch {}
2488
- }
2489
-
2490
- function renderGA4List() {
2491
- const list = $('ga4List');
2492
- const empty = $('ga4Empty');
2493
- if (!list) return;
2494
-
2495
- const { searchFilter, sortDir } = ga4State;
2496
- let visible = ga4State.events.filter(e =>
2497
- !searchFilter || e.name.toLowerCase().includes(searchFilter)
2498
- );
2499
-
2500
- // Sort: newest first (desc) or oldest first (asc)
2501
- if (sortDir === 'desc') {
2502
- visible = [...visible].reverse();
2503
- }
2504
-
2505
- empty.style.display = visible.length ? 'none' : 'flex';
2506
- list.querySelectorAll('.ga4-row').forEach(e => e.remove());
2507
-
2508
- // Cap at 500 rows
2509
- const MAX = 500;
2510
- const toRender = visible.length > MAX ? visible.slice(0, MAX) : visible;
2511
-
2512
- const frag = document.createDocumentFragment();
2513
- toRender.forEach(e => {
2514
- const row = document.createElement('div');
2515
- row.className = 'ga4-row' + (e.index === ga4State.selected ? ' selected' : '');
2516
-
2517
- const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
2518
-
2519
- const evtColor = _ga4EventColor(e.name);
2520
- const colorStyle = evtColor ? `color:${evtColor}` : '';
2521
- row.innerHTML = `
2522
- <span class="ga4-cell ga4-time">${time}</span>
2523
- <span class="ga4-cell ga4-name" style="${colorStyle}">${esc(e.name)}</span>`;
2524
-
2525
- row.addEventListener('click', () => {
2526
- ga4State.selected = e.index;
2527
- list.querySelectorAll('.ga4-row').forEach(r => r.classList.remove('selected'));
2528
- row.classList.add('selected');
2529
- renderGA4Detail(e);
2530
- });
2531
-
2532
- // Right-click to copy
2533
- row.addEventListener('contextmenu', (ev) => {
2534
- ev.preventDefault();
2535
- showContextMenu(ev, [
2536
- { label: 'Copy Event Name', action: () => navigator.clipboard.writeText(e.name) },
2537
- { label: 'Copy as JSON', action: () => navigator.clipboard.writeText(JSON.stringify({ event: e.name, params: e.params }, null, 2)) },
2538
- ]);
2539
- });
2540
-
2541
- frag.appendChild(row);
2542
- });
2543
- list.appendChild(frag);
2544
- }
2545
-
2546
- function renderGA4Detail(e) {
2547
- let detail = $('ga4Detail');
2548
- if (!detail) return;
2549
-
2550
- const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
2551
-
2552
- // Clone-replace to remove stale event listeners
2553
- const fresh = detail.cloneNode(false);
2554
- detail.parentNode.replaceChild(fresh, detail);
2555
- detail = fresh;
2556
-
2557
- // Header info
2558
- const header = document.createElement('div');
2559
- header.className = 'ga4-detail-info';
2560
- header.innerHTML = `
2561
- <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>
2562
- <div class="ga4-detail-row"><span class="ga4-detail-key">Timestamp</span><span class="ga4-detail-val">${time}</span></div>
2563
- `;
2564
- detail.appendChild(header);
2565
-
2566
- // Separator
2567
- const sep = document.createElement('div');
2568
- sep.className = 'ga4-detail-sep';
2569
- detail.appendChild(sep);
2570
-
2571
- // Parameters as key-value list with collapsible objects
2572
- if (e.params && typeof e.params === 'object') {
2573
- const keys = Object.keys(e.params).sort();
2574
- keys.forEach(key => {
2575
- const val = e.params[key];
2576
- const row = document.createElement('div');
2577
- row.className = 'ga4-param-row';
2578
-
2579
- const keyEl = document.createElement('span');
2580
- keyEl.className = 'ga4-param-key';
2581
- keyEl.textContent = key;
2582
- row.appendChild(keyEl);
2583
-
2584
- if (val && typeof val === 'object') {
2585
- // Collapsible object tree
2586
- const treeWrap = document.createElement('span');
2587
- treeWrap.className = 'ga4-param-val';
2588
- treeWrap.appendChild(createTreeNode(null, val, true));
2589
- row.appendChild(treeWrap);
2590
- } else {
2591
- const valEl = document.createElement('span');
2592
- valEl.className = 'ga4-param-val';
2593
- valEl.textContent = val === null ? 'null' : val === undefined ? 'undefined' : JSON.stringify(val);
2594
- if (typeof val === 'string') valEl.style.color = 'var(--green)';
2595
- else if (typeof val === 'number') valEl.style.color = 'var(--orange)';
2596
- else if (typeof val === 'boolean') valEl.style.color = 'var(--accent2)';
2597
- row.appendChild(valEl);
2598
- }
2599
-
2600
- detail.appendChild(row);
2601
- });
2602
- }
2603
-
2604
- // Right-click on detail
2605
- detail.addEventListener('contextmenu', (ev) => {
2606
- ev.preventDefault();
2607
- showContextMenu(ev, [
2608
- { label: 'Copy All Parameters', action: () => navigator.clipboard.writeText(JSON.stringify(e.params, null, 2)) },
2609
- { label: 'Copy Event JSON', action: () => navigator.clipboard.writeText(JSON.stringify({ event: e.name, params: e.params, timestamp: e.ts }, null, 2)) },
2610
- ]);
2611
- });
2612
- }
2613
-
2614
- function renderGA4Summary() {
2615
- const summary = $('ga4Summary');
2616
- if (!summary) return;
2617
-
2618
- const counts = {};
2619
- ga4State.events.forEach(e => {
2620
- counts[e.name] = (counts[e.name] || 0) + 1;
2621
- });
2622
-
2623
- const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
2624
-
2625
- summary.innerHTML = '';
2626
-
2627
- const totalLabel = document.createElement('span');
2628
- totalLabel.className = 'ga4-summary-label';
2629
- totalLabel.textContent = `Total: ${ga4State.events.length}`;
2630
- summary.appendChild(totalLabel);
2631
-
2632
- sorted.forEach(([name, count]) => {
2633
- const chip = document.createElement('span');
2634
- const isActive = ga4State.searchFilter === name.toLowerCase();
2635
- const chipColor = _ga4EventColor(name);
2636
- chip.className = 'ga4-summary-chip' + (isActive ? ' active' : '');
2637
- if (chipColor) {
2638
- chip.style.borderColor = chipColor;
2639
- if (isActive) chip.style.background = chipColor + '22';
2640
- chip.innerHTML = `<b style="color:${chipColor}">${esc(name)}</b><span class="chip-count">${count}</span>`;
2641
- } else {
2642
- chip.innerHTML = `<b>${esc(name)}</b><span class="chip-count">${count}</span>`;
2643
- }
2644
- chip.addEventListener('click', () => {
2645
- const search = $('ga4Search');
2646
- if (isActive) {
2647
- // Clear filter
2648
- ga4State.searchFilter = '';
2649
- if (search) search.value = '';
2650
- } else {
2651
- // Set filter to this event name
2652
- ga4State.searchFilter = name.toLowerCase();
2653
- if (search) search.value = name;
2654
- }
2655
- renderGA4List();
2656
- renderGA4Summary();
2657
- });
2658
- summary.appendChild(chip);
2659
- });
2660
- }
2661
-
2662
- // ─────────────────────────────────────────────────────────────────────────────
2663
- // REDUX PANEL
2664
- // ─────────────────────────────────────────────────────────────────────────────
2665
- function initReduxPanel() {
2666
- const panel = $('panel-redux');
2667
- panel.innerHTML = `
2668
- <div class="panel-toolbar">
2669
- <span class="panel-label">Redux</span>
2670
- <span class="badge" id="rBadge">0</span>
2671
- <input id="reduxSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter actions..." />
2672
- <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
2673
- <button class="panel-clear-btn" id="reduxClear" title="Clear redux">Clear</button>
2674
- <button class="panel-clear-btn" id="reduxSort" title="Toggle sort order">Time ▲</button>
2675
- <div class="time-travel-bar" style="border:none;padding:0;margin:0">
2676
- <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected-1)">◀</button>
2677
- <span class="tt-label" id="ttLabel">—/—</span>
2678
- <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected+1)">▶</button>
2679
- </div>
2680
- </div>
2681
- </div>
2682
- <div class="scroll-area" id="reduxContent">
2683
- <div class="empty-state" id="reduxEmpty">
2684
- <div class="icon">🔲</div>
2685
- <div class="label">No actions dispatched</div>
2686
- <div class="hint">Connect Redux store to RNDebugSDK</div>
2687
- </div>
2688
- </div>`;
2689
-
2690
- $('reduxSearch').addEventListener('input', (e) => {
2691
- state.redux.searchFilter = e.target.value.toLowerCase().trim();
2692
- renderRedux();
2693
- });
2694
-
2695
- $('reduxClear').addEventListener('click', () => {
2696
- state.redux.actions = [];
2697
- state.redux.states = [];
2698
- state.redux.selected = -1;
2699
- $('rBadge').textContent = '0';
2700
- renderRedux();
2701
- });
2702
-
2703
- $('reduxSort').addEventListener('click', () => {
2704
- state.redux.sortDir = state.redux.sortDir === 'desc' ? 'asc' : 'desc';
2705
- $('reduxSort').textContent = state.redux.sortDir === 'desc' ? 'Time \u25BC' : 'Time \u25B2';
2706
- renderRedux();
2707
- });
2708
- }
2709
-
2710
- window.reduxJumpTo = idx => {
2711
- const { actions } = state.redux;
2712
- if (!actions.length) return;
2713
- idx = Math.max(0, Math.min(actions.length - 1, idx));
2714
- state.redux.selected = idx;
2715
- renderRedux();
2716
- };
2717
-
2718
- // Fast deep equality check for Redux state comparison
2719
- function _deepEqual(a, b) {
2720
- if (a === b) return true;
2721
- if (a == null || b == null) return false;
2722
- if (typeof a !== typeof b) return false;
2723
- if (typeof a !== 'object') return false;
2724
- try {
2725
- return JSON.stringify(a) === JSON.stringify(b);
2726
- } catch { return false; }
2727
- }
2728
-
2729
- // Find leaf-level changes between two values (for Redux store diff)
2730
- function _findLeafChanges(oldVal, newVal, basePath, maxDepth) {
2731
- const changes = [];
2732
- if (maxDepth === undefined) maxDepth = 5;
2733
-
2734
- function walk(a, b, path, depth) {
2735
- if (depth > maxDepth) {
2736
- if (!_deepEqual(a, b)) changes.push({ path, oldVal: a, newVal: b });
2737
- return;
2738
- }
2739
- if (a === b) return;
2740
- if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) {
2741
- changes.push({ path, oldVal: a, newVal: b });
2742
- return;
2743
- }
2744
- const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
2745
- allKeys.forEach(k => {
2746
- if (!_deepEqual(a[k], b[k])) {
2747
- const childPath = path ? `${path}.${k}` : k;
2748
- if (a[k] != null && b[k] != null && typeof a[k] === 'object' && typeof b[k] === 'object' && !Array.isArray(a[k])) {
2749
- walk(a[k], b[k], childPath, depth + 1);
2750
- } else {
2751
- changes.push({ path: childPath, oldVal: a[k], newVal: b[k] });
2752
- }
2753
- }
2754
- });
2755
- }
2756
-
2757
- walk(oldVal, newVal, '', 0);
2758
- return changes;
2759
- }
2760
-
2761
- // Create a tree node with changed paths highlighted in a different color
2762
- function _createHighlightedTree(key, val, changedPaths, currentPath, isOld) {
2763
- const isArray = Array.isArray(val);
2764
- const isObj = val !== null && typeof val === 'object';
2765
- const myPath = key !== null ? (currentPath ? `${currentPath}.${key}` : String(key)) : currentPath;
2766
- const isChanged = changedPaths.has(myPath);
2767
-
2768
- if (!isObj) {
2769
- // Leaf value
2770
- const row = document.createElement('div');
2771
- row.className = 'ov-leaf' + (isChanged ? ' rdx-highlight' : '');
2772
- if (isChanged) row.style.cssText = isOld
2773
- ? 'background:rgba(255,94,114,.12);border-radius:3px;padding:1px 4px;'
2774
- : 'background:rgba(61,214,140,.12);border-radius:3px;padding:1px 4px;';
2775
- if (key !== null) {
2776
- const k = document.createElement('span');
2777
- k.className = 'ov-key';
2778
- k.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : '';
2779
- k.textContent = `${key}: `;
2780
- row.appendChild(k);
2781
- }
2782
- const v = document.createElement('span');
2783
- v.className = 'ov-prim';
2784
- if (isChanged) v.style.fontWeight = '700';
2785
- if (val === null) { v.textContent = 'null'; v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--text-dim)'; }
2786
- else if (typeof val === 'string') { v.textContent = `"${val}"`; v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--green)'; }
2787
- else if (typeof val === 'number') { v.textContent = String(val); v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--accent2)'; }
2788
- else if (typeof val === 'boolean') { v.textContent = String(val); v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--accent2)'; }
2789
- else { v.textContent = _safeStr(val); }
2790
- row.appendChild(v);
2791
- return row;
2792
- }
2793
-
2794
- // Object/Array — check if any descendants changed
2795
- const hasChangedDescendant = [...changedPaths].some(p => p === myPath || p.startsWith(myPath ? myPath + '.' : ''));
2796
- const container = document.createElement('div');
2797
- container.className = 'ov-node';
2798
-
2799
- const header = document.createElement('div');
2800
- header.className = 'ov-header';
2801
-
2802
- const arrow = document.createElement('span');
2803
- arrow.className = 'ov-arrow';
2804
- arrow.textContent = '\u25B6';
2805
- header.appendChild(arrow);
2806
-
2807
- if (key !== null) {
2808
- const k = document.createElement('span');
2809
- k.className = 'ov-key';
2810
- if (hasChangedDescendant) k.style.color = isOld ? 'var(--red)' : 'var(--green)';
2811
- k.textContent = `${key}: `;
2812
- header.appendChild(k);
2813
- }
2814
-
2815
- const preview = document.createElement('span');
2816
- preview.className = 'ov-preview';
2817
- preview.textContent = isArray ? `Array(${val.length})` : `{${Object.keys(val).length} keys}`;
2818
- header.appendChild(preview);
2819
-
2820
- container.appendChild(header);
2821
-
2822
- const children = document.createElement('div');
2823
- children.className = 'ov-children';
2824
- // Always start collapsed — user expands what they need
2825
- children.style.display = 'none';
2826
-
2827
- let populated = false;
2828
- function populate() {
2829
- if (populated) return;
2830
- populated = true;
2831
- const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val);
2832
- entries.forEach(([k, v]) => {
2833
- children.appendChild(_createHighlightedTree(k, v, changedPaths, myPath, isOld));
2834
- });
2835
- }
2836
-
2837
- header.addEventListener('click', (e) => {
2838
- e.stopPropagation();
2839
- const open = children.style.display !== 'none';
2840
- children.style.display = open ? 'none' : 'block';
2841
- arrow.textContent = open ? '\u25B6' : '\u25BC';
2842
- if (!open) populate();
2843
- });
2844
-
2845
- container.appendChild(children);
2846
- return container;
2847
- }
2848
-
2849
- function handleReduxEvent(event) {
2850
- if (event.type !== 'redux') return;
2851
- // Skip processing if Redux tab is disabled (saves memory)
2852
- if (!isTabEnabled('redux')) return;
2853
- const { action, nextState } = event;
2854
- const idx = state.redux.actions.length;
2855
-
2856
- const prevState = state.redux.states.length > 0 ? state.redux.states[state.redux.states.length - 1] : null;
2857
- const changedKeys = [];
2858
- if (prevState && nextState && typeof prevState === 'object' && typeof nextState === 'object') {
2859
- const allKeys = new Set([...Object.keys(prevState), ...Object.keys(nextState)]);
2860
- allKeys.forEach(k => { if (!_deepEqual(prevState[k], nextState[k])) changedKeys.push(k); });
2861
- }
2862
-
2863
- const actionEntry = { type: action?.type || '?', payload: action, ts: event.ts, index: idx, changedKeys };
2864
- state.redux.actions.push(actionEntry);
2865
- state.redux.states.push(nextState);
2866
- // Cap Redux history to prevent memory leak (full state stored per action)
2867
- const MAX_REDUX_HISTORY = 500;
2868
- if (state.redux.actions.length > MAX_REDUX_HISTORY) {
2869
- const trim = state.redux.actions.length - MAX_REDUX_HISTORY;
2870
- state.redux.actions.splice(0, trim);
2871
- state.redux.states.splice(0, trim);
2872
- // Re-index remaining actions
2873
- state.redux.actions.forEach((a, i) => a.index = i);
2874
- if (state.redux.selected >= 0) state.redux.selected = Math.max(0, state.redux.selected - trim);
2875
- }
2876
- // Don't auto-select — keep all collapsed until user clicks
2877
- $('rBadge').textContent = state.redux.actions.length;
2878
- renderRedux();
2879
-
2880
- // Always add Redux actions to console logs — visibility controlled by showRedux filter
2881
- {
2882
- const msg = `[Redux] ${actionEntry.type}` + (changedKeys.length ? ` (changed: ${changedKeys.join(', ')})` : '');
2883
- addConsoleLog({
2884
- level: 'redux',
2885
- message: msg,
2886
- args: [{ t: 'string', v: `[Redux] ${actionEntry.type}` }, { t: 'object', v: action }],
2887
- ts: event.ts,
2888
- _isRedux: true,
2889
- });
2890
- }
2891
- }
2892
-
2893
- // Assign a consistent color to each Redux action category (e.g. ANALYTICS, CART, USER)
2894
- const _reduxCatColors = {};
2895
- const _reduxColorPalette = [
2896
- 'var(--accent)', // blue
2897
- 'var(--green)', // green
2898
- 'var(--orange)', // orange
2899
- 'var(--accent2)', // purple
2900
- '#e06c75', // coral
2901
- '#56b6c2', // teal
2902
- '#c678dd', // magenta
2903
- '#d19a66', // gold
2904
- '#98c379', // lime
2905
- '#e5c07b', // yellow
2906
- ];
2907
- let _reduxColorIdx = 0;
2908
- function _reduxCategoryColor(category) {
2909
- if (!_reduxCatColors[category]) {
2910
- _reduxCatColors[category] = _reduxColorPalette[_reduxColorIdx % _reduxColorPalette.length];
2911
- _reduxColorIdx++;
2912
- }
2913
- return _reduxCatColors[category];
2914
- }
2915
-
2916
- function renderRedux() {
2917
- const content = $('reduxContent');
2918
- const empty = $('reduxEmpty');
2919
- if (!content) return;
2920
-
2921
- const { actions, states, selected, searchFilter, sortDir } = state.redux;
2922
- let visible = searchFilter ? actions.filter(a => a.type.toLowerCase().includes(searchFilter)) : [...actions];
2923
- if (sortDir === 'desc') visible = [...visible].reverse();
2924
-
2925
- empty.style.display = visible.length ? 'none' : 'flex';
2926
- content.querySelectorAll('.rdx-entry').forEach(e => e.remove());
2927
- if (!visible.length) return;
2928
-
2929
- const ttLabel = $('ttLabel');
2930
- if (ttLabel) ttLabel.textContent = selected >= 0 ? `${selected + 1}/${actions.length}` : `—/${actions.length}`;
2931
-
2932
- const frag = document.createDocumentFragment();
2933
- visible.forEach(a => {
2934
- const isSelected = a.index === selected;
2935
-
2936
- const entry = document.createElement('div');
2937
- entry.className = 'rdx-entry' + (isSelected ? ' selected' : '');
2938
-
2939
- // Row header — always visible
2940
- const header = document.createElement('div');
2941
- header.className = 'rdx-entry-header';
2942
- const changesBadge = a.changedKeys?.length ? `<span class="rdx-changes">${a.changedKeys.length} changed</span>` : '';
2943
- // Color-code action type by category prefix (e.g. ANALYTICS/, CART/, USER/)
2944
- const typeParts = a.type.split('/');
2945
- let typeHtml;
2946
- if (typeParts.length >= 2) {
2947
- const catColor = _reduxCategoryColor(typeParts[0]);
2948
- typeHtml = `<span class="rdx-type-cat" style="color:${catColor}">${esc(typeParts[0])}/</span><span class="rdx-type-name">${esc(typeParts.slice(1).join('/'))}</span>`;
2949
- } else {
2950
- typeHtml = `<span class="rdx-type">${esc(a.type)}</span>`;
2951
- }
2952
- 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>`;
2953
- // Toggle: click to expand, click again to collapse
2954
- header.addEventListener('click', () => {
2955
- state.redux.selected = isSelected ? -1 : a.index;
2956
- renderRedux();
2957
- });
2958
- // Right-click to copy action type
2959
- header.addEventListener('contextmenu', (e) => {
2960
- e.preventDefault();
2961
- e.stopPropagation();
2962
- showContextMenu(e, [
2963
- { label: 'Copy Action Type', action: () => navigator.clipboard.writeText(a.type) },
2964
- { label: 'Copy Action Payload', action: () => navigator.clipboard.writeText(JSON.stringify(a.payload, null, 2)) },
2965
- ]);
2966
- });
2967
- // Allow text selection on the action type
2968
- header.style.userSelect = 'text';
2969
- entry.appendChild(header);
2970
-
2971
- // Expanded detail — only for explicitly selected action
2972
- if (isSelected) {
2973
- const detail = document.createElement('div');
2974
- detail.className = 'rdx-entry-detail';
2975
-
2976
- // Close button
2977
- const closeBtn = document.createElement('button');
2978
- closeBtn.className = 'rdx-close-btn';
2979
- closeBtn.textContent = '✕';
2980
- closeBtn.title = 'Close';
2981
- closeBtn.addEventListener('click', (e) => {
2982
- e.stopPropagation();
2983
- state.redux.selected = -1;
2984
- renderRedux();
2985
- });
2986
- detail.appendChild(closeBtn);
2987
-
2988
- // Changed keys badges
2989
- if (a.changedKeys?.length > 0) {
2990
- const keysEl = document.createElement('div');
2991
- keysEl.className = 'redux-changed-keys';
2992
- keysEl.innerHTML = `<span class="redux-changed-label">Changed:</span> ${a.changedKeys.map(k =>
2993
- `<span class="redux-changed-key">${esc(k)}</span>`).join(' ')}`;
2994
- detail.appendChild(keysEl);
2995
- }
2996
-
2997
- // Payload
2998
- if (a.payload) {
2999
- const pLabel = document.createElement('div');
3000
- pLabel.className = 'redux-section-title';
3001
- pLabel.textContent = 'Action Payload';
3002
- detail.appendChild(pLabel);
3003
- detail.appendChild(createTreeNode(null, a.payload, false));
3004
- }
3005
-
3006
- // Store changes — two-column layout: Previous | Current
3007
- const prevS = a.index > 0 ? states[a.index - 1] : null;
3008
- const currS = states[a.index];
3009
- if (currS && typeof currS === 'object' && a.changedKeys?.length > 0) {
3010
- a.changedKeys.forEach(key => {
3011
- const keyWrap = document.createElement('div');
3012
- keyWrap.className = 'rdx-store-diff';
3013
-
3014
- const kLabel = document.createElement('div');
3015
- kLabel.className = 'rdx-store-key-label';
3016
- kLabel.textContent = key;
3017
- keyWrap.appendChild(kLabel);
3018
-
3019
- const oldVal = prevS ? prevS[key] : undefined;
3020
- const newVal = currS[key];
3021
-
3022
- // Find which sub-keys changed (for highlighting)
3023
- const changedPaths = new Set();
3024
- _findLeafChanges(oldVal, newVal, '').forEach(c => changedPaths.add(c.path));
3025
-
3026
- // Two-column grid: Previous | Current
3027
- const grid = document.createElement('div');
3028
- grid.className = 'rdx-diff-grid';
3029
-
3030
- // Previous column
3031
- const prevCol = document.createElement('div');
3032
- prevCol.className = 'rdx-diff-col prev';
3033
- const prevLabel = document.createElement('div');
3034
- prevLabel.className = 'rdx-state-label prev';
3035
- prevLabel.textContent = '- Previous';
3036
- prevCol.appendChild(prevLabel);
3037
- if (oldVal !== undefined) {
3038
- prevCol.appendChild(_createHighlightedTree(null, oldVal, changedPaths, '', true));
3039
- } else {
3040
- const na = document.createElement('span');
3041
- na.style.cssText = 'color:var(--text-dim);font-size:10px;font-style:italic';
3042
- na.textContent = 'undefined';
3043
- prevCol.appendChild(na);
3044
- }
3045
- grid.appendChild(prevCol);
3046
-
3047
- // Current column
3048
- const currCol = document.createElement('div');
3049
- currCol.className = 'rdx-diff-col curr';
3050
- const currLabel = document.createElement('div');
3051
- currLabel.className = 'rdx-state-label curr';
3052
- currLabel.textContent = '+ Current';
3053
- currCol.appendChild(currLabel);
3054
- if (newVal !== undefined) {
3055
- currCol.appendChild(_createHighlightedTree(null, newVal, changedPaths, '', false));
3056
- } else {
3057
- const na = document.createElement('span');
3058
- na.style.cssText = 'color:var(--text-dim);font-size:10px;font-style:italic';
3059
- na.textContent = 'undefined';
3060
- currCol.appendChild(na);
3061
- }
3062
- grid.appendChild(currCol);
3063
-
3064
- // Right-click to copy on each column
3065
- prevCol.addEventListener('contextmenu', (e) => {
3066
- e.preventDefault(); e.stopPropagation();
3067
- showContextMenu(e, [
3068
- { label: 'Copy Previous Value', action: () => navigator.clipboard.writeText(JSON.stringify(oldVal, null, 2)) },
3069
- { label: 'Copy Current Value', action: () => navigator.clipboard.writeText(JSON.stringify(newVal, null, 2)) },
3070
- { label: `Copy "${key}" key`, action: () => navigator.clipboard.writeText(key) },
3071
- ]);
3072
- });
3073
- currCol.addEventListener('contextmenu', (e) => {
3074
- e.preventDefault(); e.stopPropagation();
3075
- showContextMenu(e, [
3076
- { label: 'Copy Current Value', action: () => navigator.clipboard.writeText(JSON.stringify(newVal, null, 2)) },
3077
- { label: 'Copy Previous Value', action: () => navigator.clipboard.writeText(JSON.stringify(oldVal, null, 2)) },
3078
- { label: `Copy "${key}" key`, action: () => navigator.clipboard.writeText(key) },
3079
- ]);
3080
- });
3081
-
3082
- keyWrap.appendChild(grid);
3083
- detail.appendChild(keyWrap);
3084
- });
3085
- }
3086
-
3087
- entry.appendChild(detail);
3088
- }
3089
-
3090
- frag.appendChild(entry);
3091
- });
3092
-
3093
- content.appendChild(frag);
3094
- // Scroll selected entry into view
3095
- const selEl = content.querySelector('.rdx-entry.selected');
3096
- if (selEl) {
3097
- selEl.scrollIntoView({ block: 'nearest', behavior: 'auto' });
3098
- }
3099
- }
3100
-
3101
- // ─────────────────────────────────────────────────────────────────────────────
3102
- // ASYNC STORAGE PANEL
3103
- // ─────────────────────────────────────────────────────────────────────────────
3104
- function initStoragePanel() {
3105
- const panel = $('panel-storage');
3106
- panel.innerHTML = `
3107
- <div class="panel-toolbar">
3108
- <span class="panel-label">AsyncStorage</span>
3109
- <span class="badge" id="sBadge">0</span>
3110
- <div class="ml-auto">
3111
- <input id="storageSearch" class="net-search-input" placeholder="Filter keys..." />
3112
- </div>
3113
- </div>
3114
- <div class="storage-layout" id="storageLayout">
3115
- <div class="storage-keys" id="storageKeysPane">
3116
- <div class="panel-toolbar" style="height:32px">
3117
- <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Keys</span>
3118
- </div>
3119
- <div class="scroll-area storage-keys-list" id="storageKeyList">
3120
- <div class="empty-state" id="storageEmpty">
3121
- <div class="icon">💾</div>
3122
- <div class="label">No storage data</div>
3123
- <div class="hint">AsyncStorage data will appear here</div>
3124
- </div>
3125
- </div>
3126
- </div>
3127
- <div class="storage-resize-handle" id="storageResizeHandle"></div>
3128
- <div class="storage-value-view">
3129
- <div class="storage-value-toolbar">
3130
- <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Value</span>
3131
- <span id="storageSelectedKey" style="font-size:11px;color:var(--accent);margin-left:8px"></span>
3132
- </div>
3133
- <div class="storage-value-body" id="storageValueBody">
3134
- <span style="color:var(--text-dim)">Select a key to view its value</span>
3135
- </div>
3136
- </div>
3137
- </div>`;
3138
-
3139
- $('storageSearch').addEventListener('input', (e) => {
3140
- state.storage.searchFilter = e.target.value.toLowerCase().trim();
3141
- renderStorage();
3142
- });
3143
-
3144
- // Drag resize handle for key list width
3145
- const handle = $('storageResizeHandle');
3146
- const layout = $('storageLayout');
3147
- const keysPane = $('storageKeysPane');
3148
- if (handle && layout && keysPane) {
3149
- let dragging = false;
3150
- let startX = 0;
3151
- let startW = 0;
3152
- handle.addEventListener('mousedown', (e) => {
3153
- e.preventDefault();
3154
- dragging = true;
3155
- startX = e.clientX;
3156
- startW = keysPane.offsetWidth;
3157
- document.body.style.cursor = 'col-resize';
3158
- document.body.style.userSelect = 'none';
3159
- });
3160
- document.addEventListener('mousemove', (e) => {
3161
- if (!dragging) return;
3162
- const newW = Math.max(120, Math.min(600, startW + (e.clientX - startX)));
3163
- layout.style.gridTemplateColumns = `${newW}px 4px 1fr`;
3164
- });
3165
- document.addEventListener('mouseup', () => {
3166
- if (!dragging) return;
3167
- dragging = false;
3168
- document.body.style.cursor = '';
3169
- document.body.style.userSelect = '';
3170
- });
3171
- }
3172
- }
3173
-
3174
- let _storageRAF = null;
3175
-
3176
- function handleStorageEvent(event) {
3177
- if (event.type !== 'storage') return;
3178
- if (!isTabEnabled('storage')) return;
3179
- const { key, value, action } = event;
3180
- if (action === 'set' || action === 'snapshot') {
3181
- if (action === 'snapshot' && typeof key === 'object') {
3182
- // Skip if data hasn't changed
3183
- const newKeys = Object.keys(key).slice().sort().join(',');
3184
- const oldKeys = state.storage.keys.slice().sort().join(',');
3185
- if (newKeys === oldKeys) {
3186
- // Check if values changed
3187
- let same = true;
3188
- for (const [k, v] of Object.entries(key)) {
3189
- if (state.storage.entries[k] !== v) { same = false; break; }
3190
- }
3191
- if (same) return; // No changes, skip re-render
3192
- }
3193
- Object.entries(key).forEach(([k, v]) => {
3194
- state.storage.entries[k] = v;
3195
- if (!state.storage.keys.includes(k)) state.storage.keys.push(k);
3196
- });
3197
- } else {
3198
- if (state.storage.entries[key] === value) return; // No change
3199
- state.storage.entries[key] = value;
3200
- if (!state.storage.keys.includes(key)) state.storage.keys.push(key);
3201
- }
3202
- } else if (action === 'remove') {
3203
- if (!(key in state.storage.entries)) return; // Already removed
3204
- delete state.storage.entries[key];
3205
- state.storage.keys = state.storage.keys.filter(k => k !== key);
3206
- if (state.storage.selected === key) state.storage.selected = null;
3207
- }
3208
- $('sBadge').textContent = state.storage.keys.length;
3209
- // Debounce render via rAF
3210
- if (!_storageRAF) {
3211
- _storageRAF = requestAnimationFrame(() => {
3212
- _storageRAF = null;
3213
- renderStorage();
3214
- });
3215
- }
3216
- }
3217
-
3218
- function renderStorage() {
3219
- const list = $('storageKeyList');
3220
- const empty = $('storageEmpty');
3221
- if (!list) return;
3222
-
3223
- const { searchFilter } = state.storage;
3224
- const visible = state.storage.keys.filter(k =>
3225
- !searchFilter || k.toLowerCase().includes(searchFilter)
3226
- );
3227
-
3228
- empty.style.display = visible.length ? 'none' : 'flex';
3229
- list.querySelectorAll('.storage-key-row').forEach(e => e.remove());
3230
-
3231
- const frag = document.createDocumentFragment();
3232
- visible.forEach(k => {
3233
- const div = document.createElement('div');
3234
- const val = state.storage.entries[k] || '';
3235
- div.className = 'storage-key-row entry' + (k === state.storage.selected ? ' selected' : '');
3236
- div.innerHTML = `
3237
- <span class="key-name">${highlight(esc(k), searchFilter)}</span>
3238
- <span class="key-size">${formatSize(val.length)}</span>`;
3239
- div.onclick = () => { state.storage.selected = k; renderStorage(); renderStorageValue(); };
3240
- frag.appendChild(div);
3241
- });
3242
- list.appendChild(frag);
3243
- renderStorageValue();
3244
- }
3245
-
3246
- function renderStorageValue() {
3247
- let body = $('storageValueBody');
3248
- const keyLabel = $('storageSelectedKey');
3249
- if (!body) return;
3250
- const { selected, entries } = state.storage;
3251
- if (!selected) {
3252
- body.innerHTML = '<span style="color:var(--text-dim)">Select a key</span>';
3253
- if (keyLabel) keyLabel.textContent = '';
3254
- return;
3255
- }
3256
- if (keyLabel) keyLabel.textContent = selected;
3257
- // Clone-replace to remove stale event listeners
3258
- const fresh = body.cloneNode(false);
3259
- body.parentNode.replaceChild(fresh, body);
3260
- body = fresh;
3261
-
3262
- let val = entries[selected];
3263
- // Try to parse JSON strings into objects for tree display
3264
- if (typeof val === 'string') {
3265
- try { val = JSON.parse(val); } catch {}
3266
- }
3267
-
3268
- if (val && typeof val === 'object') {
3269
- body.appendChild(createTreeNode(null, val, false));
3270
- body.addEventListener('contextmenu', (e) => {
3271
- e.preventDefault();
3272
- showContextMenu(e, [
3273
- { label: 'Copy Value', action: () => navigator.clipboard.writeText(JSON.stringify(val, null, 2)) },
3274
- { label: 'Copy Key', action: () => navigator.clipboard.writeText(selected) },
3275
- ]);
3276
- });
3277
- } else {
3278
- body.innerHTML = renderJSON(val);
3279
- }
3280
- }
3281
-
3282
- function formatSize(bytes) {
3283
- if (bytes < 1024) return `${bytes}b`;
3284
- return `${(bytes/1024).toFixed(1)}kb`;
3285
- }
3286
-
3287
- // ─────────────────────────────────────────────────────────────────────────────
3288
- // REACT TREE PANEL
3289
- // ─────────────────────────────────────────────────────────────────────────────
3290
- // ─────────────────────────────────────────────────────────────────────────────
3291
- // NATIVE LOGS PANEL
3292
- // ─────────────────────────────────────────────────────────────────────────────
3293
- const _nativeState = { logs: [], connected: false, platform: null, levelFilter: 'all', searchFilter: '' };
3294
- const MAX_NATIVE_LOGS = 2000;
3295
-
3296
- function initNativeLogsPanel() {
3297
- const panel = $('panel-native');
3298
- if (!panel) return;
3299
- panel.innerHTML = `
3300
- <div class="panel-toolbar">
3301
- <span class="panel-label">Native Logs</span>
3302
- <span class="badge" id="nativeBadge">0</span>
3303
- <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
3304
- <span class="native-status" id="nativeStatus">Detecting...</span>
3305
- <button class="panel-clear-btn" id="nativeClear">Clear</button>
3306
- </div>
3307
- </div>
3308
- <div class="native-connect-panel" id="nativeConnectPanel">
3309
- <div class="native-hero">
3310
- <div style="font-size:36px;opacity:0.15;margin-bottom:12px">📱</div>
3311
- <div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:6px">Native Logs</div>
3312
- <div style="font-size:11px;color:var(--text-dim);max-width:420px;line-height:1.7;margin-bottom:20px">
3313
- Stream native crash logs, errors, and warnings directly in ReactoRadar.<br/>
3314
- No need to open Android Studio or Xcode.
3315
- </div>
3316
- <div class="native-platform-cards">
3317
- <div class="native-card" id="nativeCardAndroid">
3318
- <div class="native-card-icon">🤖</div>
3319
- <div class="native-card-title">Android</div>
3320
- <div class="native-card-hint">Requires: <code>adb</code> in PATH (Android SDK)</div>
3321
- <div class="native-card-prereq">
3322
- <div class="native-prereq-step"><b>Prerequisites:</b></div>
3323
- <div class="native-prereq-step">1. Enable <b>Developer Options</b> on device<br/><span style="color:var(--text-dim);font-size:9px">Settings → About Phone → Tap Build Number 7 times</span></div>
3324
- <div class="native-prereq-step">2. Enable <b>USB Debugging</b><br/><span style="color:var(--text-dim);font-size:9px">Settings → Developer Options → USB Debugging → ON</span></div>
3325
- <div class="native-prereq-step">3. Connect device via USB and accept the prompt</div>
3326
- <div class="native-prereq-step">4. Verify: run <code>adb devices</code> in terminal</div>
3327
- </div>
3328
- <div id="nativeAndroidStatus" class="native-detect-status"></div>
3329
- <button class="native-connect-btn" id="nativeConnectAndroid">Connect Android</button>
3330
- </div>
3331
- <div class="native-card" id="nativeCardIOS">
3332
- <div class="native-card-icon">🍎</div>
3333
- <div class="native-card-title">iOS</div>
3334
- <div class="native-card-hint">Simulator or USB device</div>
3335
- <div class="native-card-prereq">
3336
- <div class="native-prereq-step"><b>Simulator:</b></div>
3337
- <div class="native-prereq-step">Requires Xcode Command Line Tools<br/><code>xcode-select --install</code></div>
3338
- <div class="native-prereq-step" style="margin-top:6px"><b>Real Device (USB):</b></div>
3339
- <div class="native-prereq-step">1. Install: <code>brew install libimobiledevice</code></div>
3340
- <div class="native-prereq-step">2. Connect device, tap <b>Trust</b> on the prompt</div>
3341
- <div class="native-prereq-step">3. Verify: <code>idevice_id -l</code> shows device UDID</div>
3342
- </div>
3343
- <div id="nativeIOSStatus" class="native-detect-status"></div>
3344
- <div style="display:flex;gap:6px;margin-top:8px">
3345
- <button class="native-connect-btn" id="nativeConnectIOSSim">Simulator</button>
3346
- <button class="native-connect-btn" id="nativeConnectIOSDevice">USB Device</button>
3347
- </div>
3348
- </div>
3349
- </div>
3350
- </div>
3351
- </div>
3352
- <div class="native-logs-area" id="nativeLogsArea" style="display:none">
3353
- <div class="native-filter-bar">
3354
- <input id="nativeSearch" class="net-search-input" placeholder="Filter logs..." />
3355
- <div class="native-level-filters" id="nativeLevelFilters">
3356
- <button class="net-status-btn active" data-level="all">All</button>
3357
- <button class="net-status-btn" data-level="fatal">Fatal</button>
3358
- <button class="net-status-btn" data-level="error">Error</button>
3359
- <button class="net-status-btn" data-level="warn">Warn</button>
3360
- <button class="net-status-btn" data-level="info">Info</button>
3361
- <button class="net-status-btn" data-level="debug">Debug</button>
3362
- </div>
3363
- <div style="margin-left:auto;display:flex;gap:6px;align-items:center">
3364
- <button class="panel-clear-btn" id="nativeLogsClear">Clear</button>
3365
- <button class="panel-clear-btn" id="nativeDisconnect" style="color:var(--red)">Disconnect</button>
3366
- </div>
3367
- </div>
3368
- <div class="native-log-list" id="nativeLogList"></div>
3369
- </div>`;
3370
-
3371
- // Connect buttons — auto-enable tab when user clicks connect
3372
- function _enableNativeTab() {
3373
- const vis = getTabVisibility();
3374
- if (!vis['native']) {
3375
- vis['native'] = true;
3376
- setTabVisibility(vis);
3377
- applyTabVisibility();
3378
- }
3379
- }
3380
- $('nativeConnectAndroid')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('android'); });
3381
- $('nativeConnectIOSSim')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('ios-sim'); });
3382
- $('nativeConnectIOSDevice')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('ios-device'); });
3383
- $('nativeDisconnect')?.addEventListener('click', () => window.electronAPI?.stopNativeLogs());
3384
-
3385
- // Clear buttons (toolbar + logs area)
3386
- $('nativeClear')?.addEventListener('click', _clearNativeLogs);
3387
- $('nativeLogsClear')?.addEventListener('click', _clearNativeLogs);
3388
-
3389
- // Level filter
3390
- $('nativeLevelFilters')?.addEventListener('click', (e) => {
3391
- const btn = e.target.closest('.net-status-btn');
3392
- if (!btn) return;
3393
- $('nativeLevelFilters').querySelectorAll('.net-status-btn').forEach(b => b.classList.remove('active'));
3394
- btn.classList.add('active');
3395
- _nativeState.levelFilter = btn.dataset.level;
3396
- _renderNativeLogs();
3397
- });
3398
-
3399
- // Search
3400
- $('nativeSearch')?.addEventListener('input', (e) => {
3401
- _nativeState.searchFilter = e.target.value.toLowerCase().trim();
3402
- _renderNativeLogs();
3403
- });
3404
-
3405
- // IPC: receive native logs
3406
- window.electronAPI?.on('native-log', (log) => {
3407
- if (!isTabEnabled('native')) return;
3408
- _nativeState.logs.push(log);
3409
- if (_nativeState.logs.length > MAX_NATIVE_LOGS) {
3410
- _nativeState.logs = _nativeState.logs.slice(-MAX_NATIVE_LOGS);
3411
- }
3412
- $('nativeBadge').textContent = _nativeState.logs.length;
3413
- _appendNativeLog(log);
3414
- });
3415
-
3416
- // IPC: connection status
3417
- window.electronAPI?.on('native-status', (status) => {
3418
- _nativeState.connected = status.connected;
3419
- _nativeState.platform = status.platform || null;
3420
- const statusEl = $('nativeStatus');
3421
- const connectPanel = $('nativeConnectPanel');
3422
- const logsArea = $('nativeLogsArea');
3423
-
3424
- if (status.connected) {
3425
- if (statusEl) { statusEl.textContent = `Connected (${status.platform})`; statusEl.style.color = 'var(--green)'; }
3426
- if (connectPanel) connectPanel.style.display = 'none';
3427
- if (logsArea) logsArea.style.display = 'flex';
3428
- } else {
3429
- if (statusEl) {
3430
- statusEl.textContent = status.error || 'Not connected';
3431
- statusEl.style.color = status.error ? 'var(--red)' : 'var(--text-dim)';
3432
- }
3433
- if (connectPanel) connectPanel.style.display = 'flex';
3434
- if (logsArea) logsArea.style.display = 'none';
3435
- }
3436
- });
3437
-
3438
- // Auto-detect platform and auto-connect
3439
- _autoDetectNative();
3440
- }
3441
-
3442
- function _clearNativeLogs() {
3443
- _nativeState.logs = [];
3444
- if ($('nativeBadge')) $('nativeBadge').textContent = '0';
3445
- const list = $('nativeLogList');
3446
- if (list) list.innerHTML = '';
3447
- }
3448
-
3449
- async function _autoDetectNative() {
3450
- const statusEl = $('nativeStatus');
3451
- try {
3452
- const result = await window.electronAPI?.detectNativePlatform();
3453
- if (!result) { if (statusEl) { statusEl.textContent = 'Detection unavailable'; statusEl.style.color = 'var(--text-dim)'; } return; }
3454
-
3455
- // Update card statuses
3456
- const androidStatus = $('nativeAndroidStatus');
3457
- const iosStatus = $('nativeIOSStatus');
3458
- if (androidStatus) {
3459
- if (result.android) { androidStatus.innerHTML = '<span style="color:var(--green)">Device detected</span>'; }
3460
- else if (result.adbPath) { androidStatus.innerHTML = '<span style="color:var(--orange)">adb found — no device connected</span>'; }
3461
- else { androidStatus.innerHTML = '<span style="color:var(--text-dim)">adb not found</span>'; }
3462
- }
3463
- if (iosStatus) {
3464
- const parts = [];
3465
- if (result.iosSim) parts.push('<span style="color:var(--green)">Simulator running</span>');
3466
- if (result.iosDevice) parts.push('<span style="color:var(--green)">USB device detected</span>');
3467
- if (!parts.length) parts.push('<span style="color:var(--text-dim)">No device detected</span>');
3468
- iosStatus.innerHTML = parts.join(' · ');
3469
- }
3470
-
3471
- // Show detection result — user clicks Connect to start
3472
- if (result.android || result.iosSim || result.iosDevice) {
3473
- const detected = [result.android ? 'Android' : '', result.iosSim ? 'iOS Sim' : '', result.iosDevice ? 'iOS Device' : ''].filter(Boolean).join(', ');
3474
- if (statusEl) { statusEl.textContent = `Detected: ${detected} — click Connect to start`; statusEl.style.color = 'var(--accent)'; }
3475
- } else {
3476
- if (statusEl) { statusEl.textContent = 'No device detected'; statusEl.style.color = 'var(--text-dim)'; }
3477
- }
3478
- } catch {
3479
- if (statusEl) { statusEl.textContent = 'Detection failed'; statusEl.style.color = 'var(--text-dim)'; }
3480
- }
3481
- }
3482
-
3483
- function _appendNativeLog(log) {
3484
- const list = $('nativeLogList');
3485
- if (!list) return;
3486
-
3487
- // Check filters
3488
- if (_nativeState.levelFilter !== 'all' && log.level !== _nativeState.levelFilter) return;
3489
- if (_nativeState.searchFilter && !log.message?.toLowerCase().includes(_nativeState.searchFilter) && !log.tag?.toLowerCase().includes(_nativeState.searchFilter)) return;
3490
-
3491
- const isExpandable = log.level === 'error' || log.level === 'fatal' || (log.message || '').length > 200;
3492
- const row = document.createElement('div');
3493
- row.className = `native-log-row native-${log.level || 'info'}`;
3494
-
3495
- const time = log.time || new Date(log.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
3496
-
3497
- // Header line (always visible)
3498
- const header = document.createElement('div');
3499
- header.className = 'native-log-header';
3500
- header.innerHTML = `<span class="native-log-time">${esc(time)}</span>`
3501
- + `<span class="native-log-level">${esc((log.level || 'info').toUpperCase())}</span>`
3502
- + (log.tag ? `<span class="native-log-tag">${esc(log.tag)}</span>` : '')
3503
- + `<span class="native-log-preview">${esc((log.message || '').split('\\n')[0].slice(0, 200))}</span>`;
3504
- row.appendChild(header);
3505
-
3506
- // Expandable full message (for errors and long messages)
3507
- if (isExpandable) {
3508
- const fullMsg = document.createElement('div');
3509
- fullMsg.className = 'native-log-full';
3510
- fullMsg.style.display = 'none';
3511
- fullMsg.textContent = log.message || '';
3512
- row.appendChild(fullMsg);
3513
-
3514
- header.style.cursor = 'pointer';
3515
- header.addEventListener('click', () => {
3516
- const open = fullMsg.style.display !== 'none';
3517
- fullMsg.style.display = open ? 'none' : 'block';
3518
- row.classList.toggle('expanded', !open);
3519
- });
3520
- }
3521
-
3522
- // Right-click to copy
3523
- row.addEventListener('contextmenu', (e) => {
3524
- e.preventDefault();
3525
- showContextMenu(e, [
3526
- { label: 'Copy Message', action: () => navigator.clipboard.writeText(log.message || '') },
3527
- { label: 'Copy Raw Line', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
3528
- ...(log.tag ? [{ label: `Copy Tag (${log.tag})`, action: () => navigator.clipboard.writeText(log.tag) }] : []),
3529
- ]);
3530
- });
3531
-
3532
- list.appendChild(row);
3533
-
3534
- // Cap DOM rows
3535
- while (list.children.length > 1000) list.firstChild.remove();
3536
-
3537
- // Auto-scroll if near bottom
3538
- const atBottom = (list.scrollHeight - list.scrollTop - list.clientHeight) < 150;
3539
- if (atBottom) list.scrollTop = list.scrollHeight;
3540
- }
3541
-
3542
- function _renderNativeLogs() {
3543
- const list = $('nativeLogList');
3544
- if (!list) return;
3545
- list.innerHTML = '';
3546
- _nativeState.logs.forEach(log => _appendNativeLog(log));
3547
- }
3548
-
3549
- function initReactPanel() {
3550
- const panel = $('panel-react');
3551
- panel.innerHTML = `
3552
- <div class="panel-toolbar">
3553
- <span class="panel-label">React Tree</span>
3554
- </div>
3555
- <div class="react-panel-inner">
3556
- <div class="react-connect-hint" id="reactHint">
3557
- <div class="icon" style="font-size:40px;opacity:.2">⚛️</div>
3558
- <div class="label">React DevTools</div>
3559
- <div class="hint">Opens as a separate window connected to your app via port 8097</div>
3560
- <div class="hint" style="margin-top:8px;color:var(--yellow)">Note: The RN inspector overlay won't work while React DevTools is connected. Close the DevTools window to use the built-in inspector.</div>
3561
- <button class="btn-launch" id="btnReactDT" style="margin-top:12px">Open React DevTools ↗</button>
3562
- </div>
3563
- </div>`;
3564
-
3565
- $('btnReactDT').addEventListener('click', () => {
3566
- window.electronAPI?.openReactDevTools();
3567
- });
3568
- }
3569
-
3570
- // ─────────────────────────────────────────────────────────────────────────────
3571
- // SETTINGS PANEL
3572
- // ─────────────────────────────────────────────────────────────────────────────
3573
- function getStoredTheme() {
3574
- try { return localStorage.getItem('rn-debug-theme') || 'dark'; } catch { return 'dark'; }
3575
- }
3576
- function setStoredTheme(t) {
3577
- try { localStorage.setItem('rn-debug-theme', t); } catch {}
3578
- }
3579
- function getStoredFontSize() {
3580
- try { return parseInt(localStorage.getItem('rn-debug-fontsize')) || 12; } catch { return 12; }
3581
- }
3582
- function setStoredFontSize(s) {
3583
- try { localStorage.setItem('rn-debug-fontsize', String(s)); } catch {}
3584
- }
3585
-
3586
- const FONT_FAMILIES = [
3587
- { label: 'SF Mono', value: "'SFMono-Regular', 'SF Mono', monospace" },
3588
- { label: 'Menlo', value: "Menlo, monospace" },
3589
- { label: 'Monaco', value: "Monaco, monospace" },
3590
- { label: 'Courier New', value: "'Courier New', Courier, monospace" },
3591
- { label: 'System Mono', value: "monospace" },
3592
- ];
3593
- function getStoredFontFamily() {
3594
- try {
3595
- const saved = localStorage.getItem('rn-debug-fontfamily');
3596
- // Reset if saved value was a removed font
3597
- if (saved && !FONT_FAMILIES.some(f => f.value === saved)) return FONT_FAMILIES[0].value;
3598
- return saved || FONT_FAMILIES[0].value;
3599
- } catch { return FONT_FAMILIES[0].value; }
3600
- }
3601
- function setStoredFontFamily(f) {
3602
- try { localStorage.setItem('rn-debug-fontfamily', f); } catch {}
3603
- }
3604
- function applyFontFamily(family) {
3605
- document.body.style.fontFamily = family;
3606
- }
3607
-
3608
- // ─── Hidden URLs (Network tab) ───────────────────────────────────────────────
3609
- function getHiddenURLs() {
3610
- try { return JSON.parse(localStorage.getItem('rn-debug-hidden-urls') || '[]'); } catch { return []; }
3611
- }
3612
- function setHiddenURLs(list) {
3613
- try { localStorage.setItem('rn-debug-hidden-urls', JSON.stringify(list)); } catch {}
3614
- }
3615
- function addHiddenURL(url) {
3616
- // Extract the base URL (without query params) as the pattern
3617
- const pattern = url.split('?')[0];
3618
- const list = getHiddenURLs();
3619
- if (!list.includes(pattern)) {
3620
- list.push(pattern);
3621
- setHiddenURLs(list);
3622
- }
3623
- _updateHiddenBadge();
3624
- }
3625
- function removeHiddenURL(pattern) {
3626
- const list = getHiddenURLs().filter(u => u !== pattern);
3627
- setHiddenURLs(list);
3628
- _updateHiddenBadge();
3629
- }
3630
- function isURLHidden(url) {
3631
- const hidden = getHiddenURLs();
3632
- if (!hidden.length) return false;
3633
- const base = url.split('?')[0];
3634
- return hidden.some(pattern => base === pattern || base.startsWith(pattern));
3635
- }
3636
- function _updateHiddenBadge() {
3637
- const btn = $('netHiddenBtn');
3638
- if (!btn) return;
3639
- const count = getHiddenURLs().length;
3640
- btn.textContent = count > 0 ? `Hidden (${count})` : 'Hidden';
3641
- btn.style.display = count > 0 ? '' : 'none';
3642
- }
3643
-
3644
- // ─── Tab Visibility ──────────────────────────────────────────────────────────
3645
- const TAB_CONFIG = [
3646
- { id: 'console', label: 'Console', icon: '🖥', essential: true },
3647
- { id: 'network', label: 'Network', icon: '📡', essential: true },
3648
- { id: 'redux', label: 'Redux', icon: '🔲', essential: false },
3649
- { id: 'ga4', label: 'GA4 Events', icon: '📊', essential: false },
3650
- { id: 'storage', label: 'AsyncStorage', icon: '💾', essential: false },
3651
- { id: 'memory', label: 'Memory', icon: '🧠', essential: false, defaultHidden: true },
3652
- { id: 'performance', label: 'Performance', icon: '⚡', essential: false, defaultHidden: true },
3653
- { id: 'react', label: 'React Tree', icon: '⚛️', essential: false },
3654
- { id: 'native', label: 'Native Logs', icon: '📱', essential: false, defaultHidden: true },
3655
- ];
3656
- function getTabVisibility() {
3657
- try {
3658
- const saved = JSON.parse(localStorage.getItem('rn-debug-tab-visibility') || '{}');
3659
- const result = {};
3660
- TAB_CONFIG.forEach(t => { result[t.id] = saved[t.id] !== undefined ? saved[t.id] : !t.defaultHidden; });
3661
- return result;
3662
- } catch {
3663
- const result = {};
3664
- TAB_CONFIG.forEach(t => { result[t.id] = !t.defaultHidden; });
3665
- return result;
3666
- }
3667
- }
3668
- function setTabVisibility(vis) {
3669
- try { localStorage.setItem('rn-debug-tab-visibility', JSON.stringify(vis)); } catch {}
3670
- }
3671
- function getTabOrder() {
3672
- try {
3673
- const saved = JSON.parse(localStorage.getItem('rn-debug-tab-order') || '[]');
3674
- if (saved.length) {
3675
- // Merge: keep saved order, append any new tabs not in saved list
3676
- const allIds = TAB_CONFIG.map(t => t.id);
3677
- const merged = saved.filter(id => allIds.includes(id));
3678
- allIds.forEach(id => { if (!merged.includes(id)) merged.push(id); });
3679
- return merged;
3680
- }
3681
- } catch {}
3682
- return TAB_CONFIG.map(t => t.id);
3683
- }
3684
- function setTabOrder(order) {
3685
- try { localStorage.setItem('rn-debug-tab-order', JSON.stringify(order)); } catch {}
3686
- }
3687
- function applyTabVisibility() {
3688
- const vis = getTabVisibility();
3689
- const order = getTabOrder();
3690
- const nav = $('sidebar');
3691
- if (!nav) return;
3692
- // Reorder nav buttons according to saved order + hide disabled ones
3693
- // Settings button always stays last
3694
- const settingsBtn = nav.querySelector('.nav-btn[data-panel="settings"]');
3695
- const spacer = nav.querySelector('.nav-spacer');
3696
- const anchor = spacer || settingsBtn; // insert before spacer or settings
3697
- order.forEach(tabId => {
3698
- const btn = nav.querySelector(`.nav-btn[data-panel="${tabId}"]`);
3699
- if (btn) {
3700
- btn.style.display = vis[tabId] ? '' : 'none';
3701
- nav.insertBefore(btn, anchor);
3702
- }
3703
- });
3704
- // If active panel is now hidden, switch to first visible
3705
- if (!vis[state.activePanel]) {
3706
- const first = order.find(id => vis[id]);
3707
- if (first) switchPanel(first);
3708
- }
3709
- }
3710
- function isTabEnabled(tabId) {
3711
- return getTabVisibility()[tabId] !== false;
3712
- }
3713
-
3714
- function _buildTabVisGrid() {
3715
- const container = $('tabVisibilityGrid');
3716
- if (!container) return;
3717
- container.innerHTML = '';
3718
- const vis = getTabVisibility();
3719
- const order = getTabOrder();
3720
- let dragSrc = null;
3721
-
3722
- order.forEach(tabId => {
3723
- const t = TAB_CONFIG.find(c => c.id === tabId);
3724
- if (!t) return;
3725
-
3726
- const item = document.createElement('div');
3727
- item.className = `tab-vis-item ${vis[t.id] ? 'active' : 'inactive'}`;
3728
- item.dataset.tab = t.id;
3729
- item.draggable = true;
3730
-
3731
- // Drag handle
3732
- const drag = document.createElement('span');
3733
- drag.className = 'tab-vis-drag';
3734
- drag.textContent = '⠿';
3735
- item.appendChild(drag);
3736
-
3737
- // Checkbox
3738
- const check = document.createElement('input');
3739
- check.type = 'checkbox';
3740
- check.className = 'tab-vis-check';
3741
- check.checked = vis[t.id];
3742
- if (t.essential) check.disabled = true;
3743
- check.addEventListener('change', () => {
3744
- const v = getTabVisibility();
3745
- v[t.id] = check.checked;
3746
- setTabVisibility(v);
3747
- applyTabVisibility();
3748
- item.classList.toggle('active', check.checked);
3749
- item.classList.toggle('inactive', !check.checked);
3750
- });
3751
- item.appendChild(check);
3752
-
3753
- // Icon + label
3754
- const icon = document.createElement('span');
3755
- icon.className = 'tab-vis-icon';
3756
- icon.textContent = t.icon;
3757
- item.appendChild(icon);
3758
-
3759
- const label = document.createElement('span');
3760
- label.className = 'tab-vis-label';
3761
- label.textContent = t.label;
3762
- item.appendChild(label);
3763
-
3764
- if (t.essential) {
3765
- const req = document.createElement('span');
3766
- req.className = 'tab-vis-required';
3767
- req.textContent = 'Required';
3768
- item.appendChild(req);
3769
- }
3770
-
3771
- // Drag events
3772
- item.addEventListener('dragstart', (e) => {
3773
- dragSrc = item;
3774
- item.classList.add('dragging');
3775
- e.dataTransfer.effectAllowed = 'move';
3776
- });
3777
- item.addEventListener('dragend', () => {
3778
- item.classList.remove('dragging');
3779
- container.querySelectorAll('.tab-vis-item').forEach(el => el.classList.remove('drag-over'));
3780
- dragSrc = null;
3781
- });
3782
- item.addEventListener('dragover', (e) => {
3783
- e.preventDefault();
3784
- e.dataTransfer.dropEffect = 'move';
3785
- if (dragSrc && dragSrc !== item) item.classList.add('drag-over');
3786
- });
3787
- item.addEventListener('dragleave', () => {
3788
- item.classList.remove('drag-over');
3789
- });
3790
- item.addEventListener('drop', (e) => {
3791
- e.preventDefault();
3792
- item.classList.remove('drag-over');
3793
- if (!dragSrc || dragSrc === item) return;
3794
- // Reorder: move dragSrc before or after this item
3795
- const items = [...container.querySelectorAll('.tab-vis-item')];
3796
- const fromIdx = items.indexOf(dragSrc);
3797
- const toIdx = items.indexOf(item);
3798
- if (fromIdx < toIdx) {
3799
- container.insertBefore(dragSrc, item.nextSibling);
3800
- } else {
3801
- container.insertBefore(dragSrc, item);
3802
- }
3803
- // Save new order
3804
- const newOrder = [...container.querySelectorAll('.tab-vis-item')].map(el => el.dataset.tab);
3805
- setTabOrder(newOrder);
3806
- applyTabVisibility();
3807
- });
3808
-
3809
- container.appendChild(item);
3810
- });
3811
- }
3812
-
3813
- function getStoredAppName() {
3814
- try { return localStorage.getItem('rn-debug-appname') || 'ReactoRadar'; } catch { return 'ReactoRadar'; }
3815
- }
3816
- function setStoredAppName(n) {
3817
- try { localStorage.setItem('rn-debug-appname', n); } catch {}
3818
- }
3819
- function getStoredMetroPort() {
3820
- try { return parseInt(localStorage.getItem('rn-debug-metro-port')) || 8081; } catch { return 8081; }
3821
- }
3822
- function setStoredMetroPort(p) {
3823
- try { localStorage.setItem('rn-debug-metro-port', String(p)); } catch {}
3824
- }
3825
- function applyAppName(name) {
3826
- const logo = document.querySelector('.logo');
3827
- if (logo) {
3828
- // Split name — first part normal, last word in accent span
3829
- const words = name.split(/(?=[A-Z])/);
3830
- if (words.length >= 2) {
3831
- logo.innerHTML = words.slice(0, -1).join('') + '<span>' + words[words.length - 1] + '</span>';
3832
- } else {
3833
- logo.textContent = name;
3834
- }
3835
- }
3836
- document.title = name;
3837
- }
3838
-
3839
- function applyTheme(theme) {
3840
- document.documentElement.setAttribute('data-theme', theme);
3841
- // Tell main process (light themes need light nativeTheme for window chrome)
3842
- const isLight = ['light', 'solarized-light'].includes(theme);
3843
- window.electronAPI?.setTheme(isLight ? 'light' : 'dark');
3844
- }
3845
-
3846
- function applyFontSize(size) {
3847
- document.documentElement.style.setProperty('--app-font-size', size + 'px');
3848
- document.body.style.fontSize = size + 'px';
3849
- // Inject/update a <style> tag so ALL current and future elements get the size
3850
- let styleEl = document.getElementById('dynamic-font-size');
3851
- if (!styleEl) {
3852
- styleEl = document.createElement('style');
3853
- styleEl.id = 'dynamic-font-size';
3854
- document.head.appendChild(styleEl);
3855
- }
3856
- styleEl.textContent = `
3857
- .log-preview, .log-body, .log-text, .log-caller-inline,
3858
- .net-cell, .net-cell-name, .net-type, .net-initiator, .net-size, .net-time, .net-status,
3859
- .detail-content, .kv-val, .kv-key,
3860
- .rdx-type, .rdx-entry-detail, .rdx-store-key-label,
3861
- .storage-value-body, .storage-key-row,
3862
- .sources-code, .source-line-code,
3863
- .ov-leaf, .ov-key, .ov-preview, .ov-str, .ov-num, .ov-bool, .ov-null, .ov-undef,
3864
- .perf-meter-label,
3865
- .settings-label, .settings-hint {
3866
- font-size: ${size}px !important;
3867
- }
3868
- `;
3869
- const display = $('fontSizeDisplay');
3870
- if (display) display.textContent = size + 'px';
3871
- }
3872
-
3873
- function initSettingsPanel() {
3874
- const panel = $('panel-settings');
3875
- const current = getStoredTheme();
3876
- const currentSize = getStoredFontSize();
3877
- panel.innerHTML = `
3878
- <div class="panel-toolbar">
3879
- <span class="panel-label">Settings</span>
3880
- </div>
3881
- <div class="scroll-area">
3882
- <div class="settings-two-col">
3883
- <div class="settings-col-left">
3884
- <div class="settings-section">
3885
- <div class="settings-section-title">Appearance</div>
3886
- <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
3887
- <div>
3888
- <div class="settings-label">Theme</div>
3889
- <div class="settings-hint">Choose a color theme</div>
3890
- </div>
3891
- <div class="theme-grid" id="themeSwitcher"></div>
3892
- </div>
3893
- <div class="settings-row">
3894
- <div>
3895
- <div class="settings-label">Font Size</div>
3896
- <div class="settings-hint">Adjust text size</div>
3897
- </div>
3898
- <div class="font-size-control">
3899
- <button class="font-size-btn" id="fontSizeDown">A-</button>
3900
- <span class="font-size-display" id="fontSizeDisplay">${currentSize}px</span>
3901
- <button class="font-size-btn" id="fontSizeUp">A+</button>
3902
- </div>
3903
- </div>
3904
- <div class="settings-row">
3905
- <div>
3906
- <div class="settings-label">Font Family</div>
3907
- </div>
3908
- <select id="fontFamilySelect" class="net-throttle-select" style="width:150px">
3909
- ${FONT_FAMILIES.map(f => `<option value="${esc(f.value)}" ${f.value === getStoredFontFamily() ? 'selected' : ''}>${esc(f.label)}</option>`).join('')}
3910
- </select>
3911
- </div>
3912
- <div class="settings-row">
3913
- <div>
3914
- <div class="settings-label">App Name</div>
3915
- </div>
3916
- <div style="display:flex;align-items:center;gap:6px">
3917
- <input id="appNameInput" class="net-search-input" style="width:120px;text-align:center" value="${getStoredAppName()}" />
3918
- <button class="font-size-btn" id="appNameReset" title="Reset">Reset</button>
3919
- </div>
3920
- </div>
3921
- <div class="settings-row">
3922
- <div>
3923
- <div class="settings-label">Toast Notifications</div>
3924
- <div class="settings-hint">Show alerts for API errors and slow requests</div>
3925
- </div>
3926
- <label class="toggle-label" for="toastToggle">
3927
- <input type="checkbox" id="toastToggle" class="toggle-input" ${getToastsEnabled() ? 'checked' : ''} />
3928
- <span class="toggle-slider"></span>
3929
- </label>
3930
- </div>
3931
- </div>
3932
- <div class="settings-section">
3933
- <div class="settings-section-title">Connection</div>
3934
- <div class="settings-row">
3935
- <div>
3936
- <div class="settings-label">Bridge Ports</div>
3937
- <div class="settings-hint">Redux :9090 · Storage :9091 · Network :9092</div>
3938
- </div>
3939
- </div>
3940
- <div class="settings-row">
3941
- <div>
3942
- <div class="settings-label">Metro Port</div>
3943
- </div>
3944
- <input id="metroPortInput" type="number" class="net-search-input" style="width:70px;text-align:center" value="${getStoredMetroPort()}" />
3945
- </div>
3946
- </div>
3947
- <div class="settings-section">
3948
- <div class="settings-section-title">About</div>
3949
- <div class="settings-about">
3950
- <div class="about-name" id="aboutAppName">${getStoredAppName()}</div>
3951
- <div class="about-version" id="aboutVersion">v${state._appVersion || '...'}</div>
3952
- <div class="about-desc">Standalone macOS debugger for React Native.<br/>Supports Hermes, New Arch, and RN 0.74+.</div>
3953
- <div class="about-links" style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
3954
- <span class="about-link" id="linkGithub">GitHub</span>
3955
- <span class="about-link" id="linkDocs">Docs</span>
3956
- <span class="about-link" id="linkLinkedIn">LinkedIn</span>
3957
- </div>
3958
- <div style="margin-top:12px;text-align:center">
3959
- <button class="support-btn" id="linkSupport" title="Support ReactoRadar development">☕ Support this project</button>
3960
- </div>
3961
- </div>
3962
- </div>
3963
- </div>
3964
- <div class="settings-col-right">
3965
- <div class="settings-section">
3966
- <div class="settings-section-title">Panels</div>
3967
- <div class="settings-hint" style="margin-bottom:8px">Show/hide tabs and drag to reorder. Disabled tabs save memory.</div>
3968
- <div class="tab-visibility-grid" id="tabVisibilityGrid"></div>
3969
- </div>
3970
- <div class="settings-section">
3971
- <div class="settings-section-title">Keyboard Shortcuts</div>
3972
- <div class="settings-shortcut-grid">
3973
- <span class="sc-key">⌘K</span><span class="sc-label">Clear All</span>
3974
- <span class="sc-key">⌘D</span><span class="sc-label">JS Debugger</span>
3975
- <span class="sc-key">⌘R</span><span class="sc-label">React DevTools</span>
3976
- <span class="sc-key">⌘⇧T</span><span class="sc-label">Toggle Theme</span>
3977
- <span class="sc-key">⌘F</span><span class="sc-label">Find</span>
3978
- <span class="sc-key">⌘1–9</span><span class="sc-label">Switch Panels</span>
3979
- <span class="sc-key">⌘+/−</span><span class="sc-label">Zoom</span>
3980
- </div>
3981
- </div>
3982
- <div class="settings-section">
3983
- <div class="settings-section-title">Quick Start</div>
3984
- <div class="settings-hint" style="line-height:1.8;font-size:11px">
3985
- <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/>
3986
- <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/>
3987
- <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/>
3988
- <b style="color:var(--text)">4.</b> Console, Network, Redux auto-connect<br/>
3989
- <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
3990
- </div>
3991
- </div>
3992
- <div class="settings-section">
3993
- <div class="settings-section-title">Version History</div>
3994
- <div class="settings-hint" style="margin-bottom:4px">Roll back to a previous version if you notice issues.</div>
3995
- <div class="settings-hint rollback-steps" id="rollbackSteps" style="margin-bottom:10px;line-height:1.8;font-size:10px">
3996
- <b style="color:var(--text)">How to roll back:</b><br/>
3997
- <span id="rollbackDmgSteps" style="display:none">
3998
- <b style="color:var(--text)">1.</b> Click <b>Download</b> on the version you want<br/>
3999
- <b style="color:var(--text)">2.</b> Open the downloaded <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">.dmg</code> file<br/>
4000
- <b style="color:var(--text)">3.</b> Drag the app to Applications (replace existing)<br/>
4001
- <b style="color:var(--text)">4.</b> Relaunch ReactoRadar
4002
- </span>
4003
- <span id="rollbackNpmSteps" style="display:none">
4004
- <b style="color:var(--text)">1.</b> Run <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">npx reactoradar@&lt;version&gt;</code> e.g. <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">npx reactoradar@1.6.4</code><br/>
4005
- <b style="color:var(--text)">2.</b> Or pin globally: <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">npm i -g reactoradar@1.6.4</code><br/>
4006
- <b style="color:var(--text)">3.</b> Run <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">reactoradar</code> to launch
4007
- </span>
4008
- </div>
4009
- <div id="versionHistoryList" class="version-history-list">
4010
- <div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Loading versions...</div>
4011
- </div>
4012
- </div>
4013
- </div>
4014
- </div>
4015
- </div>`;
4016
-
4017
- // Build theme cards
4018
- const themes = [
4019
- { id: 'dark', name: 'Dark', colors: ['#0d0e11','#4facff','#3dd68c','#ff5e72'] },
4020
- { id: 'light', name: 'Light', colors: ['#f5f6f8','#0969da','#1a7f37','#cf222e'] },
4021
- { id: 'monokai', name: 'Monokai', colors: ['#272822','#66d9ef','#a6e22e','#f92672'] },
4022
- { id: 'dracula', name: 'Dracula', colors: ['#282a36','#8be9fd','#50fa7b','#ff5555'] },
4023
- { id: 'solarized-dark', name: 'Solarized Dark', colors: ['#002b36','#268bd2','#859900','#dc322f'] },
4024
- { id: 'solarized-light', name: 'Solarized Light', colors: ['#fdf6e3','#268bd2','#859900','#dc322f'] },
4025
- { id: 'nord', name: 'Nord', colors: ['#2e3440','#88c0d0','#a3be8c','#bf616a'] },
4026
- { id: 'github-dark', name: 'GitHub Dark', colors: ['#0d1117','#58a6ff','#3fb950','#f85149'] },
4027
- { id: 'one-dark', name: 'One Dark', colors: ['#282c34','#61afef','#98c379','#e06c75'] },
4028
- ];
4029
- // Tab visibility + drag reorder
4030
- _buildTabVisGrid();
4031
-
4032
- const grid = $('themeSwitcher');
4033
- themes.forEach(t => {
4034
- const btn = document.createElement('button');
4035
- btn.className = 'theme-card' + (current === t.id ? ' active' : '');
4036
- btn.dataset.theme = t.id;
4037
- btn.innerHTML = '<div class="theme-preview" style="background:' + t.colors[0] + '">' +
4038
- '<span style="background:' + t.colors[1] + '"></span>' +
4039
- '<span style="background:' + t.colors[2] + '"></span>' +
4040
- '<span style="background:' + t.colors[3] + '"></span>' +
4041
- '</div><div class="theme-name">' + t.name + '</div>';
4042
- grid.appendChild(btn);
4043
- });
4044
-
4045
- // Theme switcher
4046
- $('themeSwitcher').addEventListener('click', (e) => {
4047
- const btn = e.target.closest('.theme-card');
4048
- if (!btn) return;
4049
- const theme = btn.dataset.theme;
4050
- document.querySelectorAll('#themeSwitcher .theme-card').forEach(b => b.classList.remove('active'));
4051
- btn.classList.add('active');
4052
- setStoredTheme(theme);
4053
- applyTheme(theme);
4054
- });
4055
-
4056
- // About links
4057
- $('linkGithub')?.addEventListener('click', () => {
4058
- window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar');
4059
- });
4060
- $('linkDocs')?.addEventListener('click', () => {
4061
- window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar#readme');
4062
- });
4063
- $('linkLinkedIn')?.addEventListener('click', () => {
4064
- window.electronAPI?.openExternal('https://www.linkedin.com/in/sharanagoudamk/');
4065
- });
4066
- $('linkSupport')?.addEventListener('click', () => {
4067
- window.electronAPI?.openExternal('https://razorpay.me/@reactoradar');
4068
- });
4069
-
4070
- // App name
4071
- $('appNameInput').addEventListener('change', (e) => {
4072
- const name = e.target.value.trim() || 'ReactoRadar';
4073
- setStoredAppName(name);
4074
- applyAppName(name);
4075
- });
4076
- $('appNameReset').addEventListener('click', () => {
4077
- setStoredAppName('ReactoRadar');
4078
- $('appNameInput').value = 'ReactoRadar';
4079
- applyAppName('ReactoRadar');
4080
- });
4081
-
4082
- // Metro Port
4083
- $('metroPortInput')?.addEventListener('change', (e) => {
4084
- let port = parseInt(e.target.value.trim());
4085
- if (isNaN(port) || port < 1024 || port > 65535) port = 8081;
4086
- e.target.value = port;
4087
- setStoredMetroPort(port);
4088
- window.electronAPI?.setMetroPort(port);
4089
- });
4090
-
4091
- // Font size controls
4092
- $('fontSizeDown').addEventListener('click', () => {
4093
- let size = getStoredFontSize();
4094
- size = Math.max(8, size - 1);
4095
- setStoredFontSize(size);
4096
- applyFontSize(size);
4097
- });
4098
- $('fontSizeUp').addEventListener('click', () => {
4099
- let size = getStoredFontSize();
4100
- size = Math.min(20, size + 1);
4101
- setStoredFontSize(size);
4102
- applyFontSize(size);
4103
- });
4104
-
4105
- // Font family
4106
- $('fontFamilySelect')?.addEventListener('change', (e) => {
4107
- const family = e.target.value;
4108
- setStoredFontFamily(family);
4109
- applyFontFamily(family);
4110
- });
4111
-
4112
- // Toast toggle
4113
- $('toastToggle')?.addEventListener('change', (e) => {
4114
- setToastsEnabled(e.target.checked);
4115
- });
4116
-
4117
- // Apply update banner if update info arrived before settings panel was created
4118
- _applyUpdateBanner();
4119
-
4120
- // Fetch and render version history for rollback
4121
- _loadVersionHistory();
4122
- }
4123
-
4124
- function _loadVersionHistory() {
4125
- const container = $('versionHistoryList');
4126
- if (!container) return;
4127
- if (!window.electronAPI || typeof window.electronAPI.fetchReleases !== 'function') {
4128
- container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Version history not available.</div>';
4129
- return;
4130
- }
4131
-
4132
- // Show appropriate rollback steps based on install type
4133
- const isPackaged = !!state._isPackaged;
4134
- const dmgSteps = $('rollbackDmgSteps');
4135
- const npmSteps = $('rollbackNpmSteps');
4136
- if (dmgSteps) dmgSteps.style.display = isPackaged ? '' : 'none';
4137
- if (npmSteps) npmSteps.style.display = isPackaged ? 'none' : '';
4138
-
4139
- window.electronAPI.fetchReleases().then(releases => {
4140
- if (!Array.isArray(releases) || releases.length === 0) {
4141
- container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Could not load versions.</div>';
4142
- return;
4143
- }
4144
-
4145
- const currentVersion = state._appVersion || '';
4146
- container.innerHTML = '';
4147
-
4148
- releases.forEach(r => {
4149
- if (!r || !r.version) return; // skip malformed entries
4150
-
4151
- const isCurrent = r.version === currentVersion;
4152
- const row = document.createElement('div');
4153
- row.className = 'version-row' + (isCurrent ? ' version-current' : '');
4154
-
4155
- // Safe date formatting
4156
- let dateStr = '';
4157
- if (r.date) {
4158
- try {
4159
- const d = new Date(r.date);
4160
- if (!isNaN(d.getTime())) {
4161
- dateStr = d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' });
4162
- }
4163
- } catch { /* skip bad date */ }
4164
- }
4165
-
4166
- // Build action button based on install type
4167
- let actionHtml = '';
4168
- if (isCurrent) {
4169
- actionHtml = '<span class="version-installed">Installed</span>';
4170
- } else if (isPackaged) {
4171
- actionHtml = '<button class="version-install-btn" title="Download .dmg for this version">Download</button>';
4172
- } else {
4173
- actionHtml = `<button class="version-npm-btn" title="Copy npm install command">npx @${esc(r.version)}</button>`;
4174
- }
4175
-
4176
- row.innerHTML = `
4177
- <div class="version-info">
4178
- <span class="version-tag">v${esc(r.version)}${r.prerelease ? ' <span class="version-pre">pre</span>' : ''}${isCurrent ? ' <span class="version-badge">current</span>' : ''}</span>
4179
- <span class="version-date">${esc(dateStr)}</span>
4180
- </div>
4181
- <div class="version-actions">
4182
- ${actionHtml}
4183
- <button class="version-notes-btn" title="View release notes">Notes</button>
4184
- </div>`;
4185
-
4186
- // DMG download button — opens the .dmg asset or release page
4187
- const installBtn = row.querySelector('.version-install-btn');
4188
- if (installBtn) {
4189
- installBtn.addEventListener('click', () => {
4190
- const url = r.dmgUrl || r.htmlUrl || '';
4191
- if (url) {
4192
- window.electronAPI.openExternal(url);
4193
- }
4194
- });
4195
- }
4196
-
4197
- // NPM copy button — copies the npx command to clipboard
4198
- const npmBtn = row.querySelector('.version-npm-btn');
4199
- if (npmBtn) {
4200
- npmBtn.addEventListener('click', () => {
4201
- const cmd = `npx reactoradar@${r.version}`;
4202
- navigator.clipboard.writeText(cmd).then(() => {
4203
- const orig = npmBtn.textContent;
4204
- npmBtn.textContent = 'Copied!';
4205
- npmBtn.style.color = 'var(--green)';
4206
- setTimeout(() => { npmBtn.textContent = orig; npmBtn.style.color = ''; }, 2000);
4207
- }).catch(() => {});
4208
- });
4209
- }
4210
-
4211
- // Notes button — show changelog in modal
4212
- const notesBtn = row.querySelector('.version-notes-btn');
4213
- if (notesBtn) {
4214
- notesBtn.addEventListener('click', () => {
4215
- if (r.version && typeof _showChangelog === 'function') {
4216
- _showChangelog(r.version);
4217
- }
4218
- });
4219
- }
4220
-
4221
- container.appendChild(row);
4222
- });
4223
-
4224
- // If no rows were rendered (all entries were malformed)
4225
- if (container.children.length === 0) {
4226
- container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">No versions found.</div>';
4227
- }
4228
- }).catch(() => {
4229
- if (container) {
4230
- container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Could not load versions. Check your internet connection.</div>';
4231
- }
4232
- });
4233
- }
4234
-
4235
- // ─── Memory Monitor ──────────────────────────────────────────────────────────
4236
- // Check memory usage periodically and warn user before it causes blank screen
4237
- let _memoryWarningShown = false;
4238
- setInterval(() => {
4239
- if (!window.performance || !performance.memory) return;
4240
- const used = performance.memory.usedJSHeapSize;
4241
- const limit = performance.memory.jsHeapSizeLimit;
4242
- const pct = used / limit;
4243
- // Warn at 70% usage
4244
- if (pct > 0.7 && !_memoryWarningShown) {
4245
- _memoryWarningShown = true;
4246
- const banner = document.createElement('div');
4247
- banner.id = 'memoryWarning';
4248
- banner.className = 'memory-warning';
4249
- const usedMB = Math.round(used / 1024 / 1024);
4250
- banner.innerHTML = `<span>High memory usage (${usedMB}MB) — ReactoRadar may become unresponsive.</span>`
4251
- + `<button class="memory-warn-btn" id="memWarnClear">Clear All Data</button>`
4252
- + `<button class="memory-warn-btn" id="memWarnDismiss">Dismiss</button>`;
4253
- document.body.prepend(banner);
4254
- $('memWarnClear')?.addEventListener('click', () => {
4255
- // Clear all panel data
4256
- state.console.logs = []; _consolePending = [];
4257
- _lastLogMsg = ''; _lastLogRow = null; _lastLogCount = 1;
4258
- $('cBadge').textContent = '0'; renderConsole();
4259
- state.network.requests = {}; state.network.order = []; state.network.selectedId = null;
4260
- $('nBadge').textContent = '0'; renderNetwork();
4261
- state.redux.actions = []; state.redux.states = []; state.redux.selected = -1;
4262
- $('rBadge').textContent = '0'; renderRedux();
4263
- banner.remove(); _memoryWarningShown = false;
4264
- });
4265
- $('memWarnDismiss')?.addEventListener('click', () => { banner.remove(); });
4266
- }
4267
- // Reset flag when memory drops
4268
- if (pct < 0.5) _memoryWarningShown = false;
4269
- }, 30000); // Check every 30 seconds
4270
-
4271
- // Apply saved theme + font size + font family + app name on load
4272
- applyTheme(getStoredTheme());
4273
- applyFontSize(getStoredFontSize());
4274
- applyFontFamily(getStoredFontFamily());
4275
- applyAppName(getStoredAppName());
4276
- applyTabVisibility();
4277
-
4278
- // Send stored metro port to backend
4279
- window.electronAPI?.setMetroPort(getStoredMetroPort());
4280
-
4281
- // ─────────────────────────────────────────────────────────────────────────────
4282
- // SOURCES PANEL (placeholder — use JS Debugger button for breakpoints)
4283
- // ─────────────────────────────────────────────────────────────────────────────
4284
- function initSourcesPanel() {
4285
- const panel = $('panel-sources');
4286
- panel.innerHTML = `
4287
- <div class="panel-toolbar">
4288
- <span class="panel-label">Sources</span>
4289
- <div class="ml-auto" style="display:flex;gap:6px">
4290
- <button class="tb-btn" id="btnOpenSourcesExt" title="Open in separate DevTools window">Breakpoints ↗</button>
4291
- </div>
4292
- </div>
4293
- <div class="sources-layout">
4294
- <div class="sources-sidebar" id="sourcesSidebar">
4295
- <div class="panel-toolbar" style="height:32px">
4296
- <input id="sourcesSearch" class="net-search-input" style="width:100%" placeholder="Search files..." />
4297
- </div>
4298
- <div class="scroll-area sources-file-list" id="sourcesFileList">
4299
- <div class="empty-state" id="sourcesEmpty">
4300
- <div class="icon" style="font-size:28px;opacity:.2">&lt;/&gt;</div>
4301
- <div class="label">Waiting for Metro...</div>
4302
- <div class="hint">Source files will load when Metro is running</div>
4303
- </div>
4304
- </div>
4305
- </div>
4306
- <div class="sources-editor" id="sourcesEditor">
4307
- <div class="panel-toolbar" style="height:32px">
4308
- <span id="sourcesFileName" style="font-size:10px;color:var(--accent)"></span>
4309
- <span id="sourcesLineInfo" style="font-size:10px;color:var(--text-dim);margin-left:auto"></span>
4310
- </div>
4311
- <div class="scroll-area sources-code" id="sourcesCode">
4312
- <span style="color:var(--text-dim);padding:20px;display:block">Select a file to view its source</span>
4313
- </div>
4314
- </div>
4315
- </div>`;
4316
-
4317
- // Open JS Debugger for breakpoints
4318
- $('btnOpenSourcesExt').addEventListener('click', () => {
4319
- window.electronAPI?.openCDPTarget(null);
4320
- });
4321
-
4322
- // Search filter for file tree
4323
- $('sourcesSearch').addEventListener('input', (e) => {
4324
- const term = e.target.value.toLowerCase().trim();
4325
- document.querySelectorAll('#sourcesFileList .src-tree-file').forEach(row => {
4326
- const filepath = row.dataset.file || '';
4327
- const match = !term || filepath.toLowerCase().includes(term);
4328
- row.style.display = match ? '' : 'none';
4329
- });
4330
- // Show/hide folder nodes based on whether they have visible children
4331
- document.querySelectorAll('#sourcesFileList .src-tree-folder').forEach(folder => {
4332
- const visibleFiles = folder.querySelectorAll('.src-tree-file:not([style*="display: none"])');
4333
- folder.style.display = (!term || visibleFiles.length > 0) ? '' : 'none';
4334
- // Auto-expand folders when searching
4335
- if (term && visibleFiles.length > 0) {
4336
- const children = folder.querySelector('.src-tree-children');
4337
- const arrow = folder.querySelector('.src-tree-arrow');
4338
- if (children) children.style.display = 'block';
4339
- if (arrow) { arrow.textContent = '\u25BC'; arrow.classList.add('expanded'); }
4340
- }
4341
- });
4342
- });
4343
-
4344
- // Fetch the source map / bundle modules list from Metro
4345
- fetchSourceFileList();
4346
- }
4347
-
4348
- async function fetchSourceFileList() {
4349
- if (!window.electronAPI?.getSourceFileList) {
4350
- console.log('[Sources] electronAPI.getSourceFileList not available, retrying...');
4351
- setTimeout(fetchSourceFileList, 5000);
4352
- return;
4353
- }
4354
- try {
4355
- console.log('[Sources] Fetching file list from Metro...');
4356
- const result = await window.electronAPI.getSourceFileList();
4357
- console.log('[Sources] Got result:', result?.files?.length, 'files, root:', result?.root?.slice(-30));
4358
- if (result?.files && result.files.length > 0) {
4359
- state._sourcesRoot = result.root;
4360
- // Limit to 500 files max to avoid DOM overload
4361
- const files = result.files.length > 500 ? result.files.slice(0, 500) : result.files;
4362
- renderSourceFileList(files);
4363
- console.log('[Sources] Rendered', files.length, 'files');
4364
- } else {
4365
- console.log('[Sources] No files, retrying in 5s...');
4366
- setTimeout(fetchSourceFileList, 5000);
4367
- }
4368
- } catch (e) {
4369
- console.log('[Sources] Error:', e?.message || e);
4370
- setTimeout(fetchSourceFileList, 5000);
4371
- }
4372
- }
4373
-
4374
- function renderSourceFileList(files) {
4375
- const list = $('sourcesFileList');
4376
- const empty = $('sourcesEmpty');
4377
- if (!list) return;
4378
- if (!files.length) return;
4379
- if (empty) empty.style.display = 'none';
4380
- list.querySelectorAll('.src-tree-node').forEach(e => e.remove());
4381
-
4382
- // Build folder tree from file paths
4383
- const tree = {};
4384
- files.forEach(filepath => {
4385
- const parts = filepath.split('/').filter(Boolean);
4386
- let node = tree;
4387
- parts.forEach((part, i) => {
4388
- if (i === parts.length - 1) {
4389
- // File leaf
4390
- node[part] = filepath; // string = file
4391
- } else {
4392
- // Folder
4393
- if (!node[part] || typeof node[part] === 'string') node[part] = {};
4394
- node = node[part];
4395
- }
4396
- });
4397
- });
4398
-
4399
- // Render tree recursively
4400
- const frag = document.createDocumentFragment();
4401
-
4402
- // Project folders first, node_modules last
4403
- const topKeys = Object.keys(tree).sort((a, b) => {
4404
- if (a === 'node_modules') return 1;
4405
- if (b === 'node_modules') return -1;
4406
- return a.localeCompare(b);
4407
- });
4408
-
4409
- topKeys.forEach(key => {
4410
- frag.appendChild(buildSourceTreeNode(key, tree[key], 0));
4411
- });
4412
- list.appendChild(frag);
4413
- }
4414
-
4415
- function buildSourceTreeNode(name, value, depth) {
4416
- if (typeof value === 'string') {
4417
- // File leaf
4418
- const row = document.createElement('div');
4419
- row.className = 'src-tree-node src-tree-file';
4420
- row.dataset.file = value;
4421
- row.style.paddingLeft = (12 + depth * 16) + 'px';
4422
- const isNM = value.includes('node_modules');
4423
- const ext = name.split('.').pop();
4424
- const iconColor = ext === 'tsx' || ext === 'ts' ? '#3178c6'
4425
- : ext === 'jsx' || ext === 'js' ? '#f0db4f'
4426
- : ext === 'json' ? '#a0a0a0'
4427
- : ext === 'css' ? '#264de4'
4428
- : 'var(--text-dim)';
4429
- row.innerHTML = `<span class="src-file-icon" style="color:${iconColor}">●</span><span class="src-file-name" style="color:${isNM ? 'var(--text-dim)' : 'var(--text-bright)'}">${esc(name)}</span>`;
4430
- row.addEventListener('click', () => {
4431
- const fileList = $('sourcesFileList');
4432
- fileList.querySelectorAll('.src-tree-file').forEach(el => el.classList.remove('selected'));
4433
- row.classList.add('selected');
4434
- loadSourceFile(value);
4435
- });
4436
- // Search filter support
4437
- const searchInput = $('sourcesSearch');
4438
- if (searchInput && searchInput.value) {
4439
- const term = searchInput.value.toLowerCase();
4440
- if (!name.toLowerCase().includes(term) && !value.toLowerCase().includes(term)) {
4441
- row.style.display = 'none';
4442
- }
4443
- }
4444
- return row;
4445
- }
4446
-
4447
- // Folder node
4448
- const container = document.createElement('div');
4449
- container.className = 'src-tree-node src-tree-folder';
4450
-
4451
- const header = document.createElement('div');
4452
- header.className = 'src-tree-folder-header';
4453
- header.style.paddingLeft = (8 + depth * 16) + 'px';
4454
-
4455
- const arrow = document.createElement('span');
4456
- arrow.className = 'src-tree-arrow';
4457
- arrow.textContent = '\u25B6';
4458
-
4459
- const folderName = document.createElement('span');
4460
- folderName.className = 'src-folder-name';
4461
- const isNM = name === 'node_modules';
4462
- folderName.style.color = isNM ? 'var(--text-dim)' : 'var(--text)';
4463
- folderName.textContent = name;
4464
-
4465
- header.appendChild(arrow);
4466
- header.appendChild(folderName);
4467
- container.appendChild(header);
4468
-
4469
- const children = document.createElement('div');
4470
- children.className = 'src-tree-children';
4471
- // Start all folders collapsed
4472
- children.style.display = 'none';
4473
-
4474
- // Sort: folders first, then files
4475
- const entries = Object.entries(value).sort((a, b) => {
4476
- const aIsFolder = typeof a[1] === 'object';
4477
- const bIsFolder = typeof b[1] === 'object';
4478
- if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
4479
- return a[0].localeCompare(b[0]);
4480
- });
4481
-
4482
- let populated = false;
4483
- function populate() {
4484
- if (populated) return;
4485
- populated = true;
4486
- entries.forEach(([childName, childValue]) => {
4487
- children.appendChild(buildSourceTreeNode(childName, childValue, depth + 1));
4488
- });
4489
- }
4490
-
4491
- // Folders start collapsed — populate lazily on first expand
4492
- header.addEventListener('click', () => {
4493
- const isOpen = children.style.display !== 'none';
4494
- if (!isOpen) {
4495
- populate();
4496
- children.style.display = 'block';
4497
- arrow.textContent = '\u25BC';
4498
- arrow.classList.add('expanded');
4499
- } else {
4500
- children.style.display = 'none';
4501
- arrow.textContent = '\u25B6';
4502
- arrow.classList.remove('expanded');
4503
- }
4504
- });
4505
-
4506
- container.appendChild(children);
4507
- return container;
4508
- }
4509
-
4510
- async function loadSourceFile(filepath) {
4511
- const codeEl = $('sourcesCode');
4512
- const nameEl = $('sourcesFileName');
4513
- const lineEl = $('sourcesLineInfo');
4514
- if (!codeEl) return;
4515
- if (nameEl) nameEl.textContent = filepath.split('/').pop();
4516
- if (lineEl) lineEl.textContent = filepath;
4517
- codeEl.innerHTML = '<span style="color:var(--text-dim)">Loading...</span>';
4518
-
4519
- let source = null;
4520
- const root = state._sourcesRoot || '';
4521
- const fullPath = root ? `${root}/${filepath}` : filepath;
4522
-
4523
- // Strategy 1: Read from disk via IPC (most reliable)
4524
- if (window.electronAPI?.readSourceFile) {
4525
- source = await window.electronAPI.readSourceFile(fullPath);
4526
- }
4527
-
4528
- // Strategy 2: Fetch from Metro
4529
- if (!source) {
4530
- try {
4531
- const port = getStoredMetroPort();
4532
- const resp = await fetch(`http://localhost:${port}/${filepath}?platform=ios&dev=true`);
4533
- if (resp.ok) source = await resp.text();
4534
- } catch {}
4535
- }
4536
-
4537
- if (!source) {
4538
- codeEl.innerHTML = `<span style="color:var(--text-dim);padding:20px;display:block">Could not load: ${esc(filepath)}</span>`;
4539
- return;
4540
- }
4541
-
4542
- // Render with line numbers
4543
- const lines = source.split('\n');
4544
- if (lineEl) lineEl.textContent = `${filepath} (${lines.length} lines)`;
4545
- codeEl.innerHTML = '';
4546
- const pre = document.createElement('pre');
4547
- pre.className = 'source-pre';
4548
- lines.forEach((line, i) => {
4549
- const lineDiv = document.createElement('div');
4550
- lineDiv.className = 'source-line';
4551
- lineDiv.innerHTML = `<span class="source-line-num">${i + 1}</span><span class="source-line-code">${syntaxHighlight(esc(line))}</span>`;
4552
- pre.appendChild(lineDiv);
4553
- });
4554
- codeEl.appendChild(pre);
4555
- }
4556
-
4557
- // Called from cdp-targets IPC handler (no longer opens external window)
4558
-
4559
- // Called from cdp-targets IPC handler (shared, no duplicate registration)
4560
- // Sources panel uses Metro source map for file tree — CDP targets are only
4561
- // used for the "Breakpoints" button, not for the file list.
4562
- function updateSourcesPanel(targets) {
4563
- // No-op: file list is populated by fetchSourceFileList from Metro source map
4564
- }
4565
-
4566
- // ─────────────────────────────────────────────────────────────────────────────
4567
- // PERFORMANCE PANEL — FPS, render timing, JS thread
4568
- // ─────────────────────────────────────────────────────────────────────────────
4569
- const perfState = { fps: [], jsThread: [], uiThread: [], recording: false, data: [] };
4570
-
4571
- function initPerformancePanel() {
4572
- const panel = $('panel-performance');
4573
- panel.innerHTML = `
4574
- <div class="panel-toolbar">
4575
- <span class="panel-label">Performance</span>
4576
- <div class="ml-auto" style="display:flex;gap:6px">
4577
- <button class="tb-btn" id="btnPerfRecord">Record</button>
4578
- <button class="tb-btn" id="btnPerfClear">Clear</button>
4579
- </div>
4580
- </div>
4581
- <div class="perf-layout">
4582
- <div class="perf-meters">
4583
- <div class="perf-meter">
4584
- <div class="perf-meter-label">FPS</div>
4585
- <div class="perf-meter-value" id="perfFPS">—</div>
4586
- <canvas class="perf-canvas" id="perfFPSCanvas" width="200" height="60"></canvas>
4587
- </div>
4588
- <div class="perf-meter">
4589
- <div class="perf-meter-label">JS Thread</div>
4590
- <div class="perf-meter-value" id="perfJS">—</div>
4591
- <canvas class="perf-canvas" id="perfJSCanvas" width="200" height="60"></canvas>
4592
- </div>
4593
- <div class="perf-meter">
4594
- <div class="perf-meter-label">UI Thread</div>
4595
- <div class="perf-meter-value" id="perfUI">—</div>
4596
- <canvas class="perf-canvas" id="perfUICanvas" width="200" height="60"></canvas>
4597
- </div>
4598
- </div>
4599
- <div class="scroll-area perf-timeline" id="perfTimeline">
4600
- <div class="empty-state" id="perfEmpty">
4601
- <div class="icon" style="font-size:28px;opacity:.2">📊</div>
4602
- <div class="label">No performance data</div>
4603
- <div class="hint">Click "Record" to start capturing performance metrics</div>
4604
- <div class="hint">The SDK sends FPS + thread usage automatically when connected</div>
4605
- </div>
4606
- </div>
4607
- </div>`;
4608
-
4609
- $('btnPerfRecord').addEventListener('click', () => {
4610
- perfState.recording = !perfState.recording;
4611
- $('btnPerfRecord').textContent = perfState.recording ? 'Stop' : 'Record';
4612
- $('btnPerfRecord').classList.toggle('primary', perfState.recording);
4613
- if (perfState.recording) {
4614
- // Tell SDK to start sending perf data
4615
- window.electronAPI?.setNetworkCapture(true); // reuse channel
4616
- }
4617
- });
4618
-
4619
- $('btnPerfClear').addEventListener('click', () => {
4620
- perfState.fps = [];
4621
- perfState.jsThread = [];
4622
- perfState.uiThread = [];
4623
- perfState.data = [];
4624
- $('perfFPS').textContent = '—';
4625
- $('perfJS').textContent = '—';
4626
- $('perfUI').textContent = '—';
4627
- clearPerfCanvas('perfFPSCanvas');
4628
- clearPerfCanvas('perfJSCanvas');
4629
- clearPerfCanvas('perfUICanvas');
4630
- });
4631
- }
4632
-
4633
- function clearPerfCanvas(id) {
4634
- const canvas = $(id);
4635
- if (!canvas) return;
4636
- const ctx = canvas.getContext('2d');
4637
- ctx.clearRect(0, 0, canvas.width, canvas.height);
4638
- }
4639
-
4640
- function drawPerfGraph(canvasId, data, maxVal, color) {
4641
- const canvas = $(canvasId);
4642
- if (!canvas || !data.length) return;
4643
- const ctx = canvas.getContext('2d');
4644
- const w = canvas.width, h = canvas.height;
4645
- ctx.clearRect(0, 0, w, h);
4646
-
4647
- // Grid lines
4648
- ctx.strokeStyle = 'rgba(255,255,255,0.05)';
4649
- ctx.lineWidth = 1;
4650
- for (let y = 0; y < h; y += h/4) {
4651
- ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
4652
- }
4653
-
4654
- // Data line
4655
- ctx.strokeStyle = color;
4656
- ctx.lineWidth = 1.5;
4657
- ctx.beginPath();
4658
- const step = w / Math.max(data.length - 1, 1);
4659
- data.forEach((v, i) => {
4660
- const x = i * step;
4661
- const y = h - (v / maxVal) * h;
4662
- if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
4663
- });
4664
- ctx.stroke();
4665
-
4666
- // Fill under
4667
- ctx.lineTo(w, h);
4668
- ctx.lineTo(0, h);
4669
- ctx.closePath();
4670
- ctx.fillStyle = color.replace('1)', '0.1)');
4671
- ctx.fill();
4672
- }
4673
-
4674
- // Handle performance events from SDK (always updates meters, graphs only when recording)
4675
- function handlePerfEvent(event) {
4676
- if (!isTabEnabled('performance') && !isTabEnabled('memory')) return;
4677
- if (event.fps != null) {
4678
- perfState.fps.push(event.fps);
4679
- if (perfState.fps.length > 100) perfState.fps.shift();
4680
- const fpsEl = $('perfFPS');
4681
- if (fpsEl) fpsEl.textContent = event.fps + ' fps';
4682
- drawPerfGraph('perfFPSCanvas', perfState.fps, 60, 'rgba(61,214,140,1)');
4683
- }
4684
- if (event.jsThread != null) {
4685
- perfState.jsThread.push(event.jsThread);
4686
- if (perfState.jsThread.length > 100) perfState.jsThread.shift();
4687
- const jsEl = $('perfJS');
4688
- if (jsEl) jsEl.textContent = event.jsThread.toFixed(1) + 'ms';
4689
- drawPerfGraph('perfJSCanvas', perfState.jsThread, 32, 'rgba(79,172,255,1)');
4690
- }
4691
- if (event.uiThread != null) {
4692
- perfState.uiThread.push(event.uiThread);
4693
- if (perfState.uiThread.length > 100) perfState.uiThread.shift();
4694
- const uiEl = $('perfUI');
4695
- if (uiEl) uiEl.textContent = event.uiThread.toFixed(1) + 'ms';
4696
- drawPerfGraph('perfUICanvas', perfState.uiThread, 32, 'rgba(155,127,255,1)');
4697
- }
4698
- }
4699
-
4700
- // ─────────────────────────────────────────────────────────────────────────────
4701
- // MEMORY PANEL — Heap snapshot summary via Hermes CDP
4702
- // ─────────────────────────────────────────────────────────────────────────────
4703
- function initMemoryPanel() {
4704
- const panel = $('panel-memory');
4705
- panel.innerHTML = `
4706
- <div class="panel-toolbar">
4707
- <span class="panel-label">Memory</span>
4708
- <div class="ml-auto" style="display:flex;gap:6px">
4709
- <button class="tb-btn primary" id="btnHeapSnapshot">Take Heap Snapshot</button>
4710
- </div>
4711
- </div>
4712
- <div class="memory-layout">
4713
- <div class="perf-meters" style="padding:14px">
4714
- <div class="perf-meter">
4715
- <div class="perf-meter-label">JS Heap Used</div>
4716
- <div class="perf-meter-value" id="memHeapUsed">—</div>
4717
- </div>
4718
- <div class="perf-meter">
4719
- <div class="perf-meter-label">JS Heap Total</div>
4720
- <div class="perf-meter-value" id="memHeapTotal">—</div>
4721
- </div>
4722
- <div class="perf-meter">
4723
- <div class="perf-meter-label">Native Memory</div>
4724
- <div class="perf-meter-value" id="memNative">—</div>
4725
- </div>
4726
- </div>
4727
- <div class="scroll-area" id="memoryContent">
4728
- <div class="empty-state" id="memoryEmpty">
4729
- <div class="icon" style="font-size:28px;opacity:.2">🧠</div>
4730
- <div class="label">No memory data</div>
4731
- <div class="hint">Click "Take Heap Snapshot" to capture memory usage</div>
4732
- <div class="hint">Requires Hermes CDP connection (press Cmd+D first)</div>
4733
- </div>
4734
- </div>
4735
- </div>`;
4736
-
4737
- $('btnHeapSnapshot').addEventListener('click', () => {
4738
- // Request heap snapshot via CDP - this opens the DevTools window
4739
- // which has built-in Memory profiler
4740
- window.electronAPI?.openCDPTarget(null);
4741
- });
4742
- }
4743
-
4744
- // Handle memory events from SDK
4745
- function handleMemoryEvent(event) {
4746
- const hu = $('memHeapUsed'), ht = $('memHeapTotal'), mn = $('memNative');
4747
- if (event.heapUsed != null && hu) hu.textContent = formatSize(event.heapUsed);
4748
- if (event.heapTotal != null && ht) ht.textContent = formatSize(event.heapTotal);
4749
- if (event.native != null && mn) mn.textContent = formatSize(event.native);
4750
- }
4751
-
4752
- // ─────────────────────────────────────────────────────────────────────────────
4753
- // INIT
4754
- // ─────────────────────────────────────────────────────────────────────────────
4755
- initConsolePanel();
4756
- initNetworkPanel();
4757
- initGA4Panel();
4758
- initPerformancePanel();
4759
- initMemoryPanel();
4760
- initReduxPanel();
4761
- initStoragePanel();
4762
- initReactPanel();
4763
- initNativeLogsPanel();
4764
- initSettingsPanel();