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