Version not found. Please check the version and try again.

reactoradar 1.2.3

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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/app.js +2450 -0
  4. package/assets/icon.svg +54 -0
  5. package/bin/cli.js +79 -0
  6. package/bin/open-debugger.sh +9 -0
  7. package/bin/setup.js +473 -0
  8. package/index.html +82 -0
  9. package/main.js +528 -0
  10. package/package.json +76 -0
  11. package/preload.js +31 -0
  12. package/sdk/RNDebugSDK.js +540 -0
  13. package/src/main/main.js +396 -0
  14. package/src/main/preload.js +28 -0
  15. package/src/renderer/app.js +221 -0
  16. package/src/renderer/components/object-tree.js +245 -0
  17. package/src/renderer/index.html +111 -0
  18. package/src/renderer/panels/console.js +248 -0
  19. package/src/renderer/panels/memory.js +60 -0
  20. package/src/renderer/panels/network.js +559 -0
  21. package/src/renderer/panels/performance.js +144 -0
  22. package/src/renderer/panels/react.js +31 -0
  23. package/src/renderer/panels/redux.js +159 -0
  24. package/src/renderer/panels/settings.js +93 -0
  25. package/src/renderer/panels/sources.js +189 -0
  26. package/src/renderer/panels/storage.js +134 -0
  27. package/src/renderer/state.js +132 -0
  28. package/src/renderer/styles/components.css +145 -0
  29. package/src/renderer/styles/console.css +73 -0
  30. package/src/renderer/styles/main.css +229 -0
  31. package/src/renderer/styles/network.css +242 -0
  32. package/src/renderer/styles/performance.css +45 -0
  33. package/src/renderer/styles/redux.css +77 -0
  34. package/src/renderer/styles/settings.css +63 -0
  35. package/src/renderer/styles/sources.css +48 -0
  36. package/src/renderer/styles/storage.css +28 -0
  37. package/src/renderer/styles/theme-light.css +57 -0
  38. package/styles.css +1308 -0
package/app.js ADDED
@@ -0,0 +1,2450 @@
1
+ 'use strict';
2
+
3
+ // ─── State ────────────────────────────────────────────────────────────────────
4
+ const state = {
5
+ filter: '',
6
+ activePanel: 'console',
7
+ ports: {},
8
+
9
+ console: { logs: [], levelFilter: 'all', searchFilter: '', stackTraceEnabled: false },
10
+
11
+ network: {
12
+ requests: {},
13
+ order: [],
14
+ statusFilter: 'all',
15
+ typeFilter: 'all',
16
+ searchFilter: '',
17
+ throttle: 'none',
18
+ enabled: true,
19
+ selectedId: null,
20
+ sortCol: 'time',
21
+ sortDir: 'desc',
22
+ },
23
+
24
+ redux: {
25
+ actions: [],
26
+ states: [],
27
+ selected: -1,
28
+ searchFilter: '',
29
+ },
30
+
31
+ storage: {
32
+ entries: {}, // key → value string
33
+ keys: [], // ordered keys
34
+ selected: null,
35
+ searchFilter: '',
36
+ },
37
+
38
+ // Device connection tracking
39
+ connections: { redux: false, network: false, storage: false, reactDT: false },
40
+ };
41
+
42
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
43
+ const $ = id => document.getElementById(id);
44
+ const esc = s => s == null ? '' : String(s)
45
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
46
+ const ts = ms => new Date(ms).toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
47
+
48
+
49
+ function pretty(val) {
50
+ if (val == null) return '';
51
+ if (typeof val === 'string') { try { return JSON.stringify(JSON.parse(val),null,2); } catch{} return val; }
52
+ return JSON.stringify(val, null, 2);
53
+ }
54
+
55
+ function syntaxHighlight(json) {
56
+ return json
57
+ .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, m => {
58
+ if (/^"/.test(m)) return /:$/.test(m) ? `<span class="json-key">${m}</span>` : `<span class="json-str">${m}</span>`;
59
+ if (/true|false/.test(m)) return `<span class="json-bool">${m}</span>`;
60
+ if (/null/.test(m)) return `<span class="json-null">${m}</span>`;
61
+ return `<span class="json-num">${m}</span>`;
62
+ });
63
+ }
64
+
65
+ function renderJSON(val) {
66
+ try {
67
+ const str = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
68
+ return syntaxHighlight(esc(str));
69
+ } catch { return esc(String(val)); }
70
+ }
71
+
72
+ function tryURL(url) { try { return new URL(url); } catch { return null; } }
73
+
74
+ // Extract short caller display from the SDK's caller string.
75
+ // SDK now sends: "HomeScreen.tsx:42 (HomeScreen)" or "ProductDetails" or "file.tsx:10"
76
+ function extractCallerShort(caller) {
77
+ if (!caller) return '';
78
+ // Already short format from SDK — just clean up
79
+ const trimmed = caller.replace(/^\s*at\s+/, '').trim();
80
+ // If it's just a function name (no file), return as-is
81
+ if (!trimmed.includes(':') && !trimmed.includes('/')) return trimmed;
82
+ // If it's "file.tsx:42 (FuncName)", return "file.tsx:42"
83
+ const m = trimmed.match(/^([^/\\\s]+\.[jt]sx?:\d+)/);
84
+ if (m) return m[1];
85
+ // Fallback
86
+ return trimmed.length > 40 ? trimmed.slice(-40) : trimmed;
87
+ }
88
+
89
+ function highlight(html, term) {
90
+ if (!term) return html;
91
+ const re = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi');
92
+ return html.replace(re, '<mark>$1</mark>');
93
+ }
94
+
95
+ // ─── Navigation ───────────────────────────────────────────────────────────────
96
+ document.querySelectorAll('.nav-btn').forEach(btn => {
97
+ btn.addEventListener('click', () => {
98
+ const panel = btn.dataset.panel;
99
+ document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
100
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
101
+ btn.classList.add('active');
102
+ $(`panel-${panel}`).classList.add('active');
103
+ state.activePanel = panel;
104
+ });
105
+ });
106
+
107
+ // Global filter removed — each panel has its own search input
108
+
109
+ // ─── Clear (active tab only) ──────────────────────────────────────────────────
110
+ $('btnClear').addEventListener('click', clearActiveTab);
111
+
112
+ function clearActiveTab() {
113
+ switch (state.activePanel) {
114
+ case 'console':
115
+ state.console.logs = [];
116
+ _consolePending = [];
117
+ $('cBadge').textContent = '0';
118
+ renderConsole();
119
+ break;
120
+ case 'network':
121
+ state.network.requests = {};
122
+ state.network.order = [];
123
+ state.network.selectedId = null;
124
+ closeNetDetail();
125
+ $('nBadge').textContent = '0';
126
+ renderNetwork();
127
+ break;
128
+ case 'redux':
129
+ state.redux.actions = [];
130
+ state.redux.states = [];
131
+ state.redux.selected = -1;
132
+ $('rBadge').textContent = '0';
133
+ renderRedux();
134
+ break;
135
+ case 'storage':
136
+ state.storage.entries = {};
137
+ state.storage.keys = [];
138
+ state.storage.selected = null;
139
+ $('sBadge').textContent = '0';
140
+ renderStorage();
141
+ break;
142
+ case 'performance':
143
+ perfState.fps = [];
144
+ perfState.jsThread = [];
145
+ perfState.uiThread = [];
146
+ perfState.data = [];
147
+ const perfFPS = $('perfFPS'); if (perfFPS) perfFPS.textContent = '—';
148
+ const perfJS = $('perfJS'); if (perfJS) perfJS.textContent = '—';
149
+ const perfUI = $('perfUI'); if (perfUI) perfUI.textContent = '—';
150
+ clearPerfCanvas('perfFPSCanvas');
151
+ clearPerfCanvas('perfJSCanvas');
152
+ clearPerfCanvas('perfUICanvas');
153
+ break;
154
+ case 'memory':
155
+ const memHU = $('memHeapUsed'); if (memHU) memHU.textContent = '—';
156
+ const memHT = $('memHeapTotal'); if (memHT) memHT.textContent = '—';
157
+ const memN = $('memNative'); if (memN) memN.textContent = '—';
158
+ break;
159
+ default:
160
+ break;
161
+ }
162
+ }
163
+
164
+ // Clear all (used by IPC clear-all-ui from menu Cmd+K)
165
+ function clearAll() {
166
+ state.console.logs = [];
167
+ _consolePending = [];
168
+ state.network.requests = {};
169
+ state.network.order = [];
170
+ state.network.selectedId = null;
171
+ closeNetDetail();
172
+ state.redux.actions = [];
173
+ state.redux.states = [];
174
+ state.redux.selected = -1;
175
+ state.storage.entries = {};
176
+ state.storage.keys = [];
177
+ state.storage.selected = null;
178
+ $('cBadge').textContent = '0';
179
+ $('nBadge').textContent = '0';
180
+ $('rBadge').textContent = '0';
181
+ $('sBadge').textContent = '0';
182
+ renderConsole();
183
+ renderNetwork();
184
+ renderRedux();
185
+ renderStorage();
186
+ }
187
+
188
+ // ─── CDP Button ───────────────────────────────────────────────────────────────
189
+ $('btnCDP').addEventListener('click', () => {
190
+ // Tell main process to open the CDP DevTools window with the best available target
191
+ window.electronAPI?.openCDPTarget(null); // null = use latest known target
192
+ });
193
+
194
+ // ─────────────────────────────────────────────────────────────────────────────
195
+ // IPC from Main
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+ if (window.electronAPI) {
198
+ window.electronAPI.on('ports', ports => { state.ports = ports; });
199
+
200
+ window.electronAPI.on('cdp-targets', targets => {
201
+ const hasCDP = targets?.length > 0;
202
+ $('btnCDP').textContent = hasCDP
203
+ ? `JS Debugger (${targets.length}) ↗`
204
+ : 'JS Debugger ↗';
205
+ $('btnCDP').style.opacity = hasCDP ? '1' : '0.5';
206
+ if (hasCDP) {
207
+ $('btnCDP').onclick = () => window.electronAPI.openCDPTarget(targets[0].webSocketDebuggerUrl);
208
+ }
209
+
210
+ });
211
+
212
+ window.electronAPI.on('redux-event', handleReduxEvent);
213
+ window.electronAPI.on('network-event', handleNetworkEvent);
214
+ window.electronAPI.on('storage-event', handleStorageEvent);
215
+
216
+ window.electronAPI.on('perf-event', event => {
217
+ handlePerfEvent(event);
218
+ handleMemoryEvent(event);
219
+ });
220
+
221
+ window.electronAPI.on('redux-connected', on => { updateDeviceBanner('redux', on); });
222
+ window.electronAPI.on('network-connected', on => { updateDeviceBanner('network', on); });
223
+ window.electronAPI.on('storage-connected', on => { updateDeviceBanner('storage', on); });
224
+ window.electronAPI.on('react-dt-status', on => { updateDeviceBanner('reactDT', on); });
225
+
226
+ window.electronAPI.on('clear-all-ui', clearAll);
227
+
228
+ window.electronAPI.on('update-available', ({ current, latest }) => {
229
+ const banner = document.createElement('div');
230
+ banner.className = 'update-banner';
231
+ banner.innerHTML = `New version <b>v${latest}</b> available (current: v${current}).
232
+ <a class="update-link" id="updateLink">Download update</a>
233
+ <span class="update-dismiss" id="updateDismiss">&times;</span>`;
234
+ document.getElementById('app').prepend(banner);
235
+ $('updateLink')?.addEventListener('click', () => {
236
+ window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger/releases');
237
+ });
238
+ $('updateDismiss')?.addEventListener('click', () => banner.remove());
239
+ });
240
+
241
+ window.electronAPI.on('trigger-open-cdp', () => {
242
+ window.electronAPI?.openCDPTarget(null);
243
+ });
244
+
245
+ // Theme toggle from menu shortcut (Cmd+Shift+T)
246
+ window.electronAPI.on('theme-changed', theme => {
247
+ document.documentElement.setAttribute('data-theme', theme);
248
+ setStoredTheme(theme);
249
+ document.querySelectorAll('#themeSwitcher .theme-card')
250
+ .forEach(b => b.classList.toggle('active', b.dataset.theme === theme));
251
+ });
252
+ }
253
+
254
+ // ─── Device Connection Status (inline in titlebar) ───────────────────────────
255
+ function updateDeviceBanner(service, connected) {
256
+ state.connections[service] = connected;
257
+ const el = $('deviceStatus');
258
+ const text = $('deviceText');
259
+ if (!el || !text) return;
260
+
261
+ const any = state.connections.redux || state.connections.network || state.connections.storage || state.connections.reactDT;
262
+
263
+ if (any) {
264
+ el.className = 'device-status connected';
265
+ text.textContent = 'Device connected';
266
+ } else {
267
+ el.className = 'device-status waiting';
268
+ text.textContent = 'Waiting for device...';
269
+ }
270
+ }
271
+
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+ // CONSOLE PANEL
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+ function initConsolePanel() {
276
+ const panel = $('panel-console');
277
+ panel.innerHTML = `
278
+ <div class="panel-toolbar">
279
+ <span class="panel-label">Console</span>
280
+ <span class="badge" id="cBadge">0</span>
281
+ <div class="tab-row" style="margin-left:12px">
282
+ <button class="tab active" onclick="setConsoleLevel('all',this)">All</button>
283
+ <button class="tab" onclick="setConsoleLevel('log',this)">Log</button>
284
+ <button class="tab" onclick="setConsoleLevel('info',this)">Info</button>
285
+ <button class="tab" onclick="setConsoleLevel('warn',this)">Warn</button>
286
+ <button class="tab" onclick="setConsoleLevel('error',this)">Error</button>
287
+ </div>
288
+ <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
289
+ <input id="consoleSearch" class="net-search-input" placeholder="Filter logs..." />
290
+ <label class="toggle-label" for="stackTraceToggle" title="Capture stack trace (caller file:line) — disabled by default for performance">
291
+ <span class="toggle-text" id="stackTraceText">Stack OFF</span>
292
+ <input type="checkbox" id="stackTraceToggle" class="toggle-input" />
293
+ <span class="toggle-slider"></span>
294
+ </label>
295
+ </div>
296
+ </div>
297
+ <div class="scroll-area" id="consoleList">
298
+ <div class="empty-state" id="consoleEmpty">
299
+ <div class="icon">⬛</div>
300
+ <div class="label">No logs yet</div>
301
+ <div class="hint">Add RNDebugSDK.js to your app</div>
302
+ </div>
303
+ </div>`;
304
+
305
+ $('consoleSearch').addEventListener('input', (e) => {
306
+ state.console.searchFilter = e.target.value.toLowerCase().trim();
307
+ renderConsole();
308
+ });
309
+
310
+ $('stackTraceToggle').addEventListener('change', (e) => {
311
+ const enabled = e.target.checked;
312
+ state.console.stackTraceEnabled = enabled;
313
+ $('stackTraceText').textContent = enabled ? 'Stack ON' : 'Stack OFF';
314
+ // Tell the SDK to enable/disable stack capture
315
+ window.electronAPI?.setStackTraceCapture(enabled);
316
+ });
317
+ }
318
+ window.setConsoleLevel = (level, btn) => {
319
+ state.console.levelFilter = level;
320
+ document.querySelectorAll('#panel-console .tab').forEach(b => b.classList.remove('active'));
321
+ btn.classList.add('active');
322
+ renderConsole();
323
+ };
324
+
325
+ // Console is fed via IPC (network-event handled in IPC section above)
326
+
327
+ // ─── Batched console append (fixes re-render performance) ────────────────────
328
+ let _consolePending = [];
329
+ let _consoleRAF = null;
330
+
331
+ function addConsoleLog(event) {
332
+ state.console.logs.push(event);
333
+ _consolePending.push(event);
334
+ // Batch DOM updates via rAF — only one paint per frame
335
+ if (!_consoleRAF) {
336
+ _consoleRAF = requestAnimationFrame(flushConsoleBatch);
337
+ }
338
+ }
339
+
340
+ function flushConsoleBatch() {
341
+ _consoleRAF = null;
342
+ const batch = _consolePending;
343
+ _consolePending = [];
344
+ if (!batch.length) return;
345
+
346
+ $('cBadge').textContent = state.console.logs.length;
347
+
348
+ const list = $('consoleList');
349
+ const empty = $('consoleEmpty');
350
+ if (!list) return;
351
+
352
+ const { levelFilter, searchFilter } = state.console;
353
+ const frag = document.createDocumentFragment();
354
+ let added = 0;
355
+
356
+ batch.forEach(l => {
357
+ if (levelFilter !== 'all' && l.level !== levelFilter) return;
358
+ if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return;
359
+ frag.appendChild(buildLogRow(l));
360
+ added++;
361
+ });
362
+
363
+ if (added > 0) {
364
+ empty.style.display = 'none';
365
+ list.appendChild(frag);
366
+ // Keep DOM size manageable — remove oldest rows if over 500
367
+ const rows = list.querySelectorAll('.log-row');
368
+ const MAX_DOM_ROWS = 500;
369
+ if (rows.length > MAX_DOM_ROWS) {
370
+ const toRemove = rows.length - MAX_DOM_ROWS;
371
+ for (let i = 0; i < toRemove; i++) rows[i].remove();
372
+ }
373
+ list.scrollTop = list.scrollHeight;
374
+ }
375
+ }
376
+
377
+ window.electronAPI?.on('console-event', addConsoleLog);
378
+
379
+ // ─── Object Tree Renderer (Chrome DevTools-like) ─────────────────────────────
380
+ // Builds interactive, collapsible DOM nodes for objects/arrays.
381
+
382
+ function objPreview(val, maxLen) {
383
+ maxLen = maxLen || 80;
384
+ if (val === null) return 'null';
385
+ if (val === undefined) return 'undefined';
386
+ if (Array.isArray(val)) {
387
+ if (val.length === 0) return '[]';
388
+ const items = [];
389
+ let len = 2; // [ ]
390
+ for (let i = 0; i < val.length && len < maxLen; i++) {
391
+ const s = primitivePreview(val[i]);
392
+ len += s.length + 2;
393
+ items.push(s);
394
+ }
395
+ const suffix = items.length < val.length ? ', ...' : '';
396
+ return `(${val.length}) [${items.join(', ')}${suffix}]`;
397
+ }
398
+ if (typeof val === 'object') {
399
+ const keys = Object.keys(val);
400
+ if (keys.length === 0) return '{}';
401
+ const items = [];
402
+ let len = 2;
403
+ for (let i = 0; i < keys.length && len < maxLen; i++) {
404
+ const s = `${keys[i]}: ${primitivePreview(val[keys[i]])}`;
405
+ len += s.length + 2;
406
+ items.push(s);
407
+ }
408
+ const suffix = items.length < keys.length ? ', ...' : '';
409
+ return `{${items.join(', ')}${suffix}}`;
410
+ }
411
+ return primitivePreview(val);
412
+ }
413
+
414
+ function primitivePreview(val) {
415
+ if (val === null) return 'null';
416
+ if (val === undefined) return 'undefined';
417
+ if (typeof val === 'string') return val.length > 50 ? `"${val.slice(0,50)}..."` : `"${val}"`;
418
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
419
+ if (Array.isArray(val)) return `Array(${val.length})`;
420
+ if (typeof val === 'object') return `{...}`;
421
+ return String(val);
422
+ }
423
+
424
+ function createTreeNode(key, val, startCollapsed) {
425
+ const isArray = Array.isArray(val);
426
+ const isObj = val !== null && typeof val === 'object';
427
+
428
+ if (!isObj) {
429
+ // Primitive leaf
430
+ const row = document.createElement('div');
431
+ row.className = 'ov-leaf';
432
+ if (key !== null) {
433
+ const k = document.createElement('span');
434
+ k.className = 'ov-key';
435
+ k.textContent = isNaN(key) ? `${key}: ` : `${key}: `;
436
+ row.appendChild(k);
437
+ }
438
+ row.appendChild(createPrimitiveSpan(val));
439
+ return row;
440
+ }
441
+
442
+ // Collapsible object/array
443
+ const container = document.createElement('div');
444
+ container.className = 'ov-node';
445
+
446
+ const header = document.createElement('div');
447
+ header.className = 'ov-header';
448
+
449
+ const arrow = document.createElement('span');
450
+ arrow.className = 'ov-arrow';
451
+ arrow.textContent = '\u25B6'; // ▶
452
+ header.appendChild(arrow);
453
+
454
+ if (key !== null) {
455
+ const k = document.createElement('span');
456
+ k.className = 'ov-key';
457
+ k.textContent = `${key}: `;
458
+ header.appendChild(k);
459
+ }
460
+
461
+ const preview = document.createElement('span');
462
+ preview.className = 'ov-preview';
463
+ preview.textContent = objPreview(val);
464
+ header.appendChild(preview);
465
+
466
+ container.appendChild(header);
467
+
468
+ const children = document.createElement('div');
469
+ children.className = 'ov-children';
470
+ children.style.display = 'none';
471
+
472
+ let populated = false;
473
+
474
+ function populateChildren() {
475
+ if (populated) return;
476
+ populated = true;
477
+ const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val);
478
+ entries.forEach(([k, v]) => {
479
+ children.appendChild(createTreeNode(k, v, true));
480
+ });
481
+ // For arrays show length, for objects show prototype hint
482
+ if (isArray) {
483
+ const lenNode = document.createElement('div');
484
+ lenNode.className = 'ov-leaf ov-meta';
485
+ lenNode.textContent = `length: ${val.length}`;
486
+ children.appendChild(lenNode);
487
+ }
488
+ }
489
+
490
+ let expanded = !startCollapsed;
491
+ if (expanded) {
492
+ populateChildren();
493
+ children.style.display = 'block';
494
+ arrow.textContent = '\u25BC'; // ▼
495
+ arrow.classList.add('expanded');
496
+ preview.style.display = 'none';
497
+ }
498
+
499
+ header.addEventListener('click', (e) => {
500
+ e.stopPropagation();
501
+ expanded = !expanded;
502
+ if (expanded) {
503
+ populateChildren();
504
+ children.style.display = 'block';
505
+ arrow.textContent = '\u25BC';
506
+ arrow.classList.add('expanded');
507
+ preview.style.display = 'none';
508
+ } else {
509
+ children.style.display = 'none';
510
+ arrow.textContent = '\u25B6';
511
+ arrow.classList.remove('expanded');
512
+ preview.style.display = '';
513
+ }
514
+ });
515
+
516
+ container.appendChild(children);
517
+ return container;
518
+ }
519
+
520
+ function createPrimitiveSpan(val) {
521
+ const s = document.createElement('span');
522
+ if (val === null) { s.className = 'ov-null'; s.textContent = 'null'; }
523
+ else if (val === undefined) { s.className = 'ov-undef'; s.textContent = 'undefined'; }
524
+ else if (typeof val === 'string') { s.className = 'ov-str'; s.textContent = `"${val}"`; }
525
+ else if (typeof val === 'number') { s.className = 'ov-num'; s.textContent = String(val); }
526
+ else if (typeof val === 'boolean') { s.className = 'ov-bool'; s.textContent = String(val); }
527
+ else { s.textContent = String(val); }
528
+ return s;
529
+ }
530
+
531
+ // Parse a structured arg from the SDK (or fall back to raw message string)
532
+ function renderConsoleArg(arg) {
533
+ if (!arg || typeof arg !== 'object' || !arg.t) {
534
+ // Backward compat: raw string
535
+ const s = document.createElement('span');
536
+ s.className = 'ov-str';
537
+ s.textContent = String(arg);
538
+ return s;
539
+ }
540
+ const { t, v } = arg;
541
+ if (t === 'string') {
542
+ const s = document.createElement('span');
543
+ s.className = 'log-text';
544
+ s.textContent = v;
545
+ return s;
546
+ }
547
+ if (t === 'number') { return createPrimitiveSpan(v); }
548
+ if (t === 'boolean') { return createPrimitiveSpan(v); }
549
+ if (t === 'null') { return createPrimitiveSpan(null); }
550
+ if (t === 'undefined') { return createPrimitiveSpan(undefined); }
551
+ if (t === 'object' || t === 'array') {
552
+ return createTreeNode(null, v, false);
553
+ }
554
+ const s = document.createElement('span');
555
+ s.textContent = String(v);
556
+ return s;
557
+ }
558
+
559
+ // Build the body of a console log row. If structured args exist, render each;
560
+ // otherwise fall back to the flat message string and try to detect JSON in it.
561
+ function buildLogBody(logEntry) {
562
+ const container = document.createElement('div');
563
+ container.className = 'log-body';
564
+
565
+ if (logEntry.args && Array.isArray(logEntry.args) && logEntry.args.length > 0) {
566
+ // Structured args from updated SDK
567
+ logEntry.args.forEach((arg, i) => {
568
+ if (i > 0) container.appendChild(document.createTextNode(' '));
569
+ container.appendChild(renderConsoleArg(arg));
570
+ });
571
+ } else if (logEntry.message != null) {
572
+ // Legacy / flat message — try to parse JSON objects out of it
573
+ const msg = String(logEntry.message);
574
+ // Try parsing the whole message as JSON
575
+ try {
576
+ const parsed = JSON.parse(msg);
577
+ if (typeof parsed === 'object' && parsed !== null) {
578
+ container.appendChild(createTreeNode(null, parsed, false));
579
+ return container;
580
+ }
581
+ } catch {}
582
+
583
+ // Otherwise render as text, but look for embedded JSON blocks
584
+ // If it looks like it contains JSON, try to pretty-render inline
585
+ const jsonRe = /(\{[\s\S]*\}|\[[\s\S]*\])/;
586
+ const match = msg.match(jsonRe);
587
+ if (match && match[0].length > 2) {
588
+ try {
589
+ const parsed = JSON.parse(match[0]);
590
+ // There's text before/after
591
+ const before = msg.slice(0, match.index);
592
+ const after = msg.slice(match.index + match[0].length);
593
+ if (before) container.appendChild(document.createTextNode(before));
594
+ container.appendChild(createTreeNode(null, parsed, false));
595
+ if (after) container.appendChild(document.createTextNode(after));
596
+ return container;
597
+ } catch {}
598
+ }
599
+
600
+ // Plain text
601
+ const span = document.createElement('span');
602
+ span.className = 'log-text';
603
+ span.textContent = msg;
604
+ container.appendChild(span);
605
+ }
606
+
607
+ return container;
608
+ }
609
+
610
+ function buildLogRow(l) {
611
+ const div = document.createElement('div');
612
+ div.className = `log-row entry ${l.level}`;
613
+
614
+ const timeSpan = document.createElement('span');
615
+ timeSpan.className = 'log-time';
616
+ timeSpan.textContent = ts(l.ts);
617
+ div.appendChild(timeSpan);
618
+
619
+ const lvlSpan = document.createElement('span');
620
+ lvlSpan.className = `lvl-badge lvl-${l.level}`;
621
+ lvlSpan.textContent = l.level;
622
+ div.appendChild(lvlSpan);
623
+
624
+ // Body wrapper with preview (collapsed) and full (expanded)
625
+ const bodyWrap = document.createElement('div');
626
+ bodyWrap.className = 'log-body-wrap';
627
+
628
+ // Single-line preview with caller at end
629
+ const preview = document.createElement('div');
630
+ preview.className = 'log-preview';
631
+ const msgText = (l.message || '').replace(/\n/g, ' ').slice(0, 200);
632
+ const previewText = document.createElement('span');
633
+ previewText.textContent = msgText + ((l.message || '').length > 200 ? '...' : '');
634
+ preview.appendChild(previewText);
635
+ if (l.caller) {
636
+ // Extract short filename:line from caller like "at Component (file.js:42:10)"
637
+ const callerShort = extractCallerShort(l.caller);
638
+ if (callerShort) {
639
+ const callerTag = document.createElement('span');
640
+ callerTag.className = 'log-caller-inline';
641
+ callerTag.textContent = callerShort;
642
+ preview.appendChild(callerTag);
643
+ }
644
+ }
645
+ bodyWrap.appendChild(preview);
646
+
647
+ // Full content (hidden by default)
648
+ const full = document.createElement('div');
649
+ full.className = 'log-full';
650
+ full.style.display = 'none';
651
+ full.appendChild(buildLogBody(l));
652
+ if (l.caller) {
653
+ const callerSpan = document.createElement('span');
654
+ callerSpan.className = 'log-caller';
655
+ callerSpan.textContent = l.caller;
656
+ full.appendChild(callerSpan);
657
+ }
658
+ bodyWrap.appendChild(full);
659
+
660
+ // Expand/collapse arrow
661
+ const arrow = document.createElement('span');
662
+ arrow.className = 'log-arrow';
663
+ arrow.textContent = '\u25B6';
664
+ bodyWrap.prepend(arrow);
665
+
666
+ let expanded = false;
667
+ // Only toggle on click, NOT on text selection drag
668
+ let _mouseDownPos = null;
669
+ bodyWrap.addEventListener('mousedown', (e) => {
670
+ _mouseDownPos = { x: e.clientX, y: e.clientY };
671
+ });
672
+ bodyWrap.addEventListener('click', (e) => {
673
+ // Don't toggle if user is selecting text (dragged mouse)
674
+ if (_mouseDownPos) {
675
+ const dx = Math.abs(e.clientX - _mouseDownPos.x);
676
+ const dy = Math.abs(e.clientY - _mouseDownPos.y);
677
+ if (dx > 3 || dy > 3) return; // user dragged to select
678
+ }
679
+ // Don't toggle if there's an active text selection
680
+ const sel = window.getSelection();
681
+ if (sel && sel.toString().length > 0) return;
682
+ // Don't toggle if clicking inside object tree expander
683
+ if (e.target.closest('.ov-header')) return;
684
+ expanded = !expanded;
685
+ if (expanded) {
686
+ preview.style.display = 'none';
687
+ full.style.display = 'block';
688
+ arrow.textContent = '\u25BC';
689
+ arrow.classList.add('expanded');
690
+ } else {
691
+ preview.style.display = '';
692
+ full.style.display = 'none';
693
+ arrow.textContent = '\u25B6';
694
+ arrow.classList.remove('expanded');
695
+ }
696
+ });
697
+
698
+ // Right-click → copy options
699
+ div.addEventListener('contextmenu', (e) => {
700
+ e.preventDefault();
701
+ const items = [];
702
+
703
+ // Copy selected text
704
+ const sel = window.getSelection();
705
+ if (sel && sel.toString().length > 0) {
706
+ items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
707
+ }
708
+
709
+ // Copy full log message
710
+ items.push({ label: 'Copy Message', action: () => {
711
+ navigator.clipboard.writeText(l.message || '');
712
+ }});
713
+
714
+ // Copy as JSON (if structured args exist)
715
+ if (l.args && l.args.length > 0) {
716
+ items.push({ label: 'Copy as JSON', action: () => {
717
+ const json = l.args.map(a => {
718
+ if (a.t === 'object' || a.t === 'array') return JSON.stringify(a.v, null, 2);
719
+ return String(a.v);
720
+ }).join(' ');
721
+ navigator.clipboard.writeText(json);
722
+ }});
723
+ }
724
+
725
+ // Copy caller location
726
+ if (l.caller) {
727
+ items.push({ label: 'Copy Caller', action: () => navigator.clipboard.writeText(l.caller) });
728
+ }
729
+
730
+ showContextMenu(e, items);
731
+ });
732
+
733
+ div.appendChild(bodyWrap);
734
+ return div;
735
+ }
736
+
737
+ // ─── Shared context menu helper ──────────────────────────────────────────────
738
+ function showContextMenu(e, items) {
739
+ document.querySelectorAll('.ctx-menu').forEach(el => el.remove());
740
+ const menu = document.createElement('div');
741
+ menu.className = 'ctx-menu';
742
+ items.forEach(({ label, action }) => {
743
+ const item = document.createElement('div');
744
+ item.className = 'ctx-item';
745
+ item.textContent = label;
746
+ item.addEventListener('click', () => { action(); menu.remove(); });
747
+ menu.appendChild(item);
748
+ });
749
+ menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
750
+ menu.style.top = Math.min(e.clientY, window.innerHeight - items.length * 32 - 10) + 'px';
751
+ document.body.appendChild(menu);
752
+ setTimeout(() => {
753
+ const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close); } };
754
+ document.addEventListener('click', close);
755
+ }, 0);
756
+ }
757
+
758
+ // Full re-render — only used on filter/level change, NOT on every incoming log
759
+ function renderConsole() {
760
+ const list = $('consoleList');
761
+ const empty = $('consoleEmpty');
762
+ if (!list) return;
763
+
764
+ const { levelFilter, searchFilter } = state.console;
765
+ const visible = state.console.logs.filter(l => {
766
+ if (levelFilter !== 'all' && l.level !== levelFilter) return false;
767
+ if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return false;
768
+ return true;
769
+ });
770
+
771
+ list.querySelectorAll('.log-row').forEach(e => e.remove());
772
+ empty.style.display = visible.length ? 'none' : 'flex';
773
+
774
+ // Render only the last 500 visible rows for performance
775
+ const MAX_RENDER = 500;
776
+ const toRender = visible.length > MAX_RENDER ? visible.slice(-MAX_RENDER) : visible;
777
+ if (visible.length > MAX_RENDER) {
778
+ const info = document.createElement('div');
779
+ info.className = 'log-row';
780
+ info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;text-align:center;font-style:italic';
781
+ info.textContent = `${visible.length - MAX_RENDER} older logs hidden for performance`;
782
+ list.appendChild(info);
783
+ }
784
+
785
+ const frag = document.createDocumentFragment();
786
+ toRender.forEach(l => frag.appendChild(buildLogRow(l)));
787
+ list.appendChild(frag);
788
+ list.scrollTop = list.scrollHeight;
789
+ }
790
+
791
+ // ─────────────────────────────────────────────────────────────────────────────
792
+ // NETWORK PANEL (Chrome DevTools-style)
793
+ // ─────────────────────────────────────────────────────────────────────────────
794
+ const NET_COLS = [
795
+ { key: 'name', label: 'Name', width: 260, min: 100 },
796
+ { key: 'status', label: 'Status', width: 60, min: 40 },
797
+ { key: 'type', label: 'Type', width: 70, min: 40 },
798
+ { key: 'initiator', label: 'Initiator', width: 90, min: 50 },
799
+ { key: 'size', label: 'Size', width: 70, min: 40 },
800
+ { key: 'time', label: 'Time', width: 70, min: 40 },
801
+ { key: 'waterfall', label: 'Waterfall', width: 120, min: 60 },
802
+ ];
803
+
804
+ function initNetworkPanel() {
805
+ const panel = $('panel-network');
806
+ panel.innerHTML = `
807
+ <div class="panel-toolbar">
808
+ <span class="panel-label">Network</span>
809
+ <span class="badge" id="nBadge">0</span>
810
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
811
+ <label class="toggle-label" for="netToggle">
812
+ <span class="toggle-text" id="netToggleText">Capture ON</span>
813
+ <input type="checkbox" id="netToggle" class="toggle-input" checked />
814
+ <span class="toggle-slider"></span>
815
+ </label>
816
+ </div>
817
+ </div>
818
+ <div class="net-filter-bar" id="netFilterBar">
819
+ <input id="netSearchInput" class="net-search-input" placeholder="Filter URLs..." />
820
+ <div class="net-type-filters" id="netTypeFilters">
821
+ <button class="net-type-btn active" data-type="all">All</button>
822
+ <button class="net-type-btn" data-type="fetch">Fetch/XHR</button>
823
+ <button class="net-type-btn" data-type="js">JS</button>
824
+ <button class="net-type-btn" data-type="css">CSS</button>
825
+ <button class="net-type-btn" data-type="img">Img</button>
826
+ <button class="net-type-btn" data-type="media">Media</button>
827
+ <button class="net-type-btn" data-type="font">Font</button>
828
+ <button class="net-type-btn" data-type="doc">Doc</button>
829
+ <button class="net-type-btn" data-type="ws">WS</button>
830
+ </div>
831
+ <div class="net-throttle" id="netThrottle">
832
+ <select id="netThrottleSelect" class="net-throttle-select">
833
+ <option value="none">No throttling</option>
834
+ <option value="fast3g">Fast 3G</option>
835
+ <option value="slow3g">Slow 3G</option>
836
+ <option value="offline">Offline</option>
837
+ </select>
838
+ </div>
839
+ </div>
840
+ <div class="net-layout">
841
+ <div class="net-table-wrap" id="netTableWrap">
842
+ <div class="net-header" id="netHeader"></div>
843
+ <div class="net-rows" id="netRows">
844
+ <div class="empty-state" id="networkEmpty">
845
+ <div class="icon">📡</div>
846
+ <div class="label">No requests yet</div>
847
+ <div class="hint">API calls will appear here automatically</div>
848
+ </div>
849
+ </div>
850
+ </div>
851
+ <div class="net-detail-pane" id="netDetailPane">
852
+ <div class="net-detail-bar">
853
+ <div class="detail-tabs" id="netDetailTabs"></div>
854
+ <button class="detail-close" id="netDetailClose" title="Close">&times;</button>
855
+ </div>
856
+ <div class="detail-content" id="netDetailContent"></div>
857
+ </div>
858
+ </div>`;
859
+
860
+ $('netToggle').addEventListener('change', (e) => {
861
+ state.network.enabled = e.target.checked;
862
+ $('netToggleText').textContent = e.target.checked ? 'Capture ON' : 'Capture OFF';
863
+ window.electronAPI?.setNetworkCapture(e.target.checked);
864
+ });
865
+
866
+ // Network search input
867
+ $('netSearchInput').addEventListener('input', (e) => {
868
+ state.network.searchFilter = e.target.value.toLowerCase().trim();
869
+ renderNetwork();
870
+ });
871
+
872
+ // Type filter buttons
873
+ $('netTypeFilters').addEventListener('click', (e) => {
874
+ const btn = e.target.closest('.net-type-btn');
875
+ if (!btn) return;
876
+ $('netTypeFilters').querySelectorAll('.net-type-btn').forEach(b => b.classList.remove('active'));
877
+ btn.classList.add('active');
878
+ state.network.typeFilter = btn.dataset.type;
879
+ renderNetwork();
880
+ });
881
+
882
+ // Throttle select
883
+ $('netThrottleSelect').addEventListener('change', (e) => {
884
+ state.network.throttle = e.target.value;
885
+ // Send throttle config to the RN app
886
+ window.electronAPI?.setNetworkThrottle(state.network.throttle);
887
+ });
888
+
889
+ // Close detail button
890
+ $('netDetailClose').addEventListener('click', closeNetDetail);
891
+
892
+ buildNetHeader();
893
+ }
894
+
895
+ // ─── Column header with sort icons + full-height resize handles ──────────────
896
+ function buildNetHeader() {
897
+ const header = $('netHeader');
898
+ header.innerHTML = '';
899
+ NET_COLS.forEach((col, i) => {
900
+ const cell = document.createElement('div');
901
+ cell.className = 'net-hcell';
902
+ cell.style.width = col.width + 'px';
903
+ cell.dataset.col = col.key;
904
+
905
+ const label = document.createElement('span');
906
+ label.className = 'net-hcell-label';
907
+ label.textContent = col.label;
908
+ cell.appendChild(label);
909
+
910
+ if (col.key !== 'waterfall') {
911
+ const sortIcon = document.createElement('span');
912
+ sortIcon.className = 'net-sort-icon';
913
+ if (state.network.sortCol === col.key) {
914
+ sortIcon.textContent = state.network.sortDir === 'asc' ? ' \u25B2' : ' \u25BC';
915
+ sortIcon.classList.add('active');
916
+ }
917
+ cell.appendChild(sortIcon);
918
+ cell.addEventListener('click', (e) => {
919
+ if (e.target.closest('.net-hcell-resize')) return;
920
+ if (state.network.sortCol === col.key) {
921
+ state.network.sortDir = state.network.sortDir === 'asc' ? 'desc' : 'asc';
922
+ } else {
923
+ state.network.sortCol = col.key;
924
+ state.network.sortDir = col.key === 'name' ? 'asc' : 'desc';
925
+ }
926
+ buildNetHeader();
927
+ renderNetwork();
928
+ });
929
+ cell.style.cursor = 'pointer';
930
+ }
931
+
932
+ // Resize handle in header
933
+ if (i < NET_COLS.length - 1) {
934
+ const handle = document.createElement('div');
935
+ handle.className = 'net-hcell-resize';
936
+ handle.addEventListener('mousedown', (e) => startColResize(e, col));
937
+ cell.appendChild(handle);
938
+ }
939
+ header.appendChild(cell);
940
+ });
941
+
942
+ // Build full-height resize overlay lines
943
+ buildResizeOverlays();
944
+ }
945
+
946
+ function buildResizeOverlays() {
947
+ // Remove old overlays
948
+ document.querySelectorAll('.net-resize-overlay').forEach(e => e.remove());
949
+ const tableWrap = $('netTableWrap');
950
+ if (!tableWrap) return;
951
+ // Make the table wrap position:relative for overlay positioning
952
+ tableWrap.style.position = 'relative';
953
+
954
+ let leftOffset = 0;
955
+ NET_COLS.forEach((col, i) => {
956
+ leftOffset += col.width;
957
+ if (i >= NET_COLS.length - 1) return; // no handle after last column
958
+
959
+ const overlay = document.createElement('div');
960
+ overlay.className = 'net-resize-overlay';
961
+ overlay.style.left = (leftOffset - 3) + 'px';
962
+ overlay.addEventListener('mousedown', (e) => startColResize(e, col));
963
+ tableWrap.appendChild(overlay);
964
+ });
965
+ }
966
+
967
+ function startColResize(e, col) {
968
+ e.preventDefault();
969
+ e.stopPropagation();
970
+ const startX = e.clientX;
971
+ const startW = col.width;
972
+
973
+ // Add visual feedback
974
+ document.body.style.cursor = 'col-resize';
975
+ document.body.style.userSelect = 'none';
976
+
977
+ function onMove(ev) {
978
+ const delta = ev.clientX - startX;
979
+ col.width = Math.max(col.min, startW + delta);
980
+ // Update header + all data cells for this column
981
+ document.querySelectorAll(`.net-cell[data-col="${col.key}"], .net-hcell[data-col="${col.key}"]`)
982
+ .forEach(el => el.style.width = col.width + 'px');
983
+ // Keep detail pane aligned with Name column
984
+ if (col.key === 'name' && state.network.selectedId) {
985
+ const pane = $('netDetailPane');
986
+ if (pane) pane.style.left = (col.width + 1) + 'px';
987
+ }
988
+ // Reposition overlays
989
+ buildResizeOverlays();
990
+ }
991
+ function onUp() {
992
+ document.body.style.cursor = '';
993
+ document.body.style.userSelect = '';
994
+ document.removeEventListener('mousemove', onMove);
995
+ document.removeEventListener('mouseup', onUp);
996
+ }
997
+ document.addEventListener('mousemove', onMove);
998
+ document.addEventListener('mouseup', onUp);
999
+ }
1000
+
1001
+ // ─── Network type matching ──────────────────────────────────────────────────
1002
+ function matchNetType(r, type) {
1003
+ const ct = (r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '').toLowerCase();
1004
+ const url = (r.url || '').toLowerCase();
1005
+ switch (type) {
1006
+ case 'fetch': return true; // All XHR/fetch requests pass
1007
+ case 'js': return ct.includes('javascript') || url.endsWith('.js') || url.endsWith('.bundle');
1008
+ case 'css': return ct.includes('css') || url.endsWith('.css');
1009
+ case 'img': return ct.includes('image') || /\.(png|jpg|jpeg|gif|svg|webp|ico)(\?|$)/i.test(url);
1010
+ case 'media': return ct.includes('video') || ct.includes('audio') || /\.(mp4|mp3|wav|webm)(\?|$)/i.test(url);
1011
+ case 'font': return ct.includes('font') || /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(url);
1012
+ case 'doc': return ct.includes('html') || ct.includes('xml');
1013
+ case 'ws': return url.startsWith('ws://') || url.startsWith('wss://');
1014
+ default: return true;
1015
+ }
1016
+ }
1017
+
1018
+ let _netRAF = null;
1019
+
1020
+ function handleNetworkEvent(event) {
1021
+ if (event.type === 'console') { addConsoleLog(event); return; }
1022
+ if (event.type !== 'network') return;
1023
+ if (!state.network.enabled) return;
1024
+
1025
+ const { id, phase } = event;
1026
+ if (phase === 'request') {
1027
+ state.network.requests[id] = { ...event, _tab: 'headers' };
1028
+ if (!state.network.order.includes(id)) state.network.order.push(id);
1029
+ $('nBadge').textContent = state.network.order.length;
1030
+ } else {
1031
+ Object.assign(state.network.requests[id] || (state.network.requests[id] = {}), event);
1032
+ }
1033
+ if (!_netRAF) {
1034
+ _netRAF = requestAnimationFrame(() => {
1035
+ _netRAF = null;
1036
+ renderNetwork();
1037
+ });
1038
+ }
1039
+ }
1040
+
1041
+ // ─── Sort network IDs ───────────────────────────────────────────────────────
1042
+ function sortNetworkIds(ids) {
1043
+ const { sortCol, sortDir } = state.network;
1044
+ const reqs = state.network.requests;
1045
+ const sorted = [...ids].sort((a, b) => {
1046
+ const ra = reqs[a], rb = reqs[b];
1047
+ if (!ra || !rb) return 0;
1048
+ let va, vb;
1049
+ switch (sortCol) {
1050
+ case 'name':
1051
+ va = (ra.url || '').toLowerCase(); vb = (rb.url || '').toLowerCase();
1052
+ return va < vb ? -1 : va > vb ? 1 : 0;
1053
+ case 'status':
1054
+ va = ra.status || 0; vb = rb.status || 0;
1055
+ return va - vb;
1056
+ case 'type':
1057
+ va = (ra.responseHeaders?.['content-type'] || '').toLowerCase();
1058
+ vb = (rb.responseHeaders?.['content-type'] || '').toLowerCase();
1059
+ return va < vb ? -1 : va > vb ? 1 : 0;
1060
+ case 'size':
1061
+ // Use cached size or estimate — avoid JSON.stringify in sort comparator
1062
+ va = ra._cachedSize ?? (ra._cachedSize = typeof ra.responseBody === 'string' ? ra.responseBody.length : (ra.responseBody != null ? 100 : 0));
1063
+ vb = rb._cachedSize ?? (rb._cachedSize = typeof rb.responseBody === 'string' ? rb.responseBody.length : (rb.responseBody != null ? 100 : 0));
1064
+ return va - vb;
1065
+ case 'time':
1066
+ default:
1067
+ va = ra.ts || 0; vb = rb.ts || 0;
1068
+ return va - vb;
1069
+ }
1070
+ });
1071
+ if (sortDir === 'desc') sorted.reverse();
1072
+ return sorted;
1073
+ }
1074
+
1075
+ // ─── Render network rows ────────────────────────────────────────────────────
1076
+ function renderNetwork() {
1077
+ const rows = $('netRows');
1078
+ const empty = $('networkEmpty');
1079
+ if (!rows) return;
1080
+
1081
+ const { statusFilter, typeFilter, searchFilter } = state.network;
1082
+ const visible = state.network.order.filter(id => {
1083
+ const r = state.network.requests[id];
1084
+ if (!r) return false;
1085
+ if (statusFilter === '2xx' && !(r.status >= 200 && r.status < 300)) return false;
1086
+ if (statusFilter === 'errors' && !(r.phase === 'error' || r.status >= 400)) return false;
1087
+ if (searchFilter && !r.url?.toLowerCase().includes(searchFilter)) return false;
1088
+ if (typeFilter !== 'all' && !matchNetType(r, typeFilter)) return false;
1089
+ return true;
1090
+ });
1091
+
1092
+ // Sort: apply current sort, default = newest first
1093
+ const sortedVisible = sortNetworkIds(visible);
1094
+
1095
+ empty.style.display = sortedVisible.length ? 'none' : 'flex';
1096
+ rows.querySelectorAll('.net-row').forEach(e => e.remove());
1097
+
1098
+ // Waterfall scale: find min/max timestamps
1099
+ let wfMin = Infinity, wfMax = 0;
1100
+ sortedVisible.forEach(id => {
1101
+ const r = state.network.requests[id];
1102
+ if (r.ts) { wfMin = Math.min(wfMin, r.ts); wfMax = Math.max(wfMax, r.ts + (r.duration || 0)); }
1103
+ });
1104
+ const wfRange = Math.max(wfMax - wfMin, 1);
1105
+
1106
+ // Render max 300 rows for performance
1107
+ const MAX_NET_ROWS = 300;
1108
+ const toRender = sortedVisible.length > MAX_NET_ROWS ? sortedVisible.slice(0, MAX_NET_ROWS) : sortedVisible;
1109
+
1110
+ const frag = document.createDocumentFragment();
1111
+ if (sortedVisible.length > MAX_NET_ROWS) {
1112
+ const info = document.createElement('div');
1113
+ info.className = 'net-row';
1114
+ info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;justify-content:center;font-style:italic';
1115
+ info.textContent = `Showing ${MAX_NET_ROWS} of ${sortedVisible.length} requests`;
1116
+ frag.appendChild(info);
1117
+ }
1118
+ toRender.forEach(id => {
1119
+ const r = state.network.requests[id];
1120
+ frag.appendChild(buildNetRow(r, wfMin, wfRange));
1121
+ });
1122
+ rows.appendChild(frag);
1123
+ }
1124
+
1125
+ function buildNetRow(r, wfMin, wfRange) {
1126
+ const row = document.createElement('div');
1127
+ row.className = 'net-row' + (r.id === state.network.selectedId ? ' selected' : '') + (r.phase === 'error' ? ' error' : '');
1128
+ row.dataset.id = r.id;
1129
+
1130
+ const urlObj = tryURL(r.url);
1131
+ const pathname = urlObj ? urlObj.pathname : r.url || '';
1132
+ const filename = pathname.split('/').filter(Boolean).pop() || pathname;
1133
+ const host = urlObj ? urlObj.host : '';
1134
+
1135
+ // Name — show method + full path (expands with column)
1136
+ const nameCell = document.createElement('div');
1137
+ nameCell.className = 'net-cell net-cell-name';
1138
+ nameCell.dataset.col = 'name';
1139
+ nameCell.style.width = NET_COLS[0].width + 'px';
1140
+ const method = r.method || '?';
1141
+ const mClass = ['GET','POST','PUT','PATCH','DELETE'].includes(method) ? `m-${method}` : 'm-other';
1142
+ const fullPath = urlObj ? urlObj.pathname + urlObj.search : r.url || '';
1143
+ nameCell.innerHTML = `<span class="method-badge ${mClass}">${method}</span> <span class="net-path" title="${esc(r.url)}">${esc(fullPath)}</span><span class="net-host">${esc(host)}</span>`;
1144
+ row.appendChild(nameCell);
1145
+
1146
+ // Status
1147
+ const statusCell = document.createElement('div');
1148
+ statusCell.className = 'net-cell net-status';
1149
+ statusCell.dataset.col = 'status';
1150
+ statusCell.style.width = NET_COLS[1].width + 'px';
1151
+ let statusStr = '...', sCls = 's-pending';
1152
+ if (r.phase === 'error') { statusStr = 'ERR'; sCls = 's-err'; }
1153
+ else if (r.status) { statusStr = String(r.status); sCls = `s-${Math.floor(r.status/100)}`; }
1154
+ statusCell.className += ` ${sCls}`;
1155
+ statusCell.textContent = statusStr;
1156
+ row.appendChild(statusCell);
1157
+
1158
+ // Type (content-type from response headers)
1159
+ const typeCell = document.createElement('div');
1160
+ typeCell.className = 'net-cell net-type';
1161
+ typeCell.dataset.col = 'type';
1162
+ typeCell.style.width = NET_COLS[2].width + 'px';
1163
+ const ct = r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '';
1164
+ typeCell.textContent = ct.split(';')[0].replace('application/', '').replace('text/', '') || '—';
1165
+ row.appendChild(typeCell);
1166
+
1167
+ // Initiator
1168
+ const initCell = document.createElement('div');
1169
+ initCell.className = 'net-cell net-initiator';
1170
+ initCell.dataset.col = 'initiator';
1171
+ initCell.style.width = NET_COLS[3].width + 'px';
1172
+ initCell.textContent = r.initiator || 'xhr';
1173
+ row.appendChild(initCell);
1174
+
1175
+ // Size
1176
+ const sizeCell = document.createElement('div');
1177
+ sizeCell.className = 'net-cell net-size';
1178
+ sizeCell.dataset.col = 'size';
1179
+ sizeCell.style.width = NET_COLS[4].width + 'px';
1180
+ const bodyStr = typeof r.responseBody === 'string' ? r.responseBody : (r.responseBody != null ? JSON.stringify(r.responseBody) : '');
1181
+ sizeCell.textContent = bodyStr.length > 0 ? formatSize(bodyStr.length) : '—';
1182
+ row.appendChild(sizeCell);
1183
+
1184
+ // Time
1185
+ const timeCell = document.createElement('div');
1186
+ timeCell.className = 'net-cell net-time' + ((r.duration || 0) > 1500 ? ' slow' : '');
1187
+ timeCell.dataset.col = 'time';
1188
+ timeCell.style.width = NET_COLS[5].width + 'px';
1189
+ timeCell.textContent = r.duration != null ? (r.duration > 999 ? `${(r.duration/1000).toFixed(1)}s` : `${r.duration}ms`) : '...';
1190
+ row.appendChild(timeCell);
1191
+
1192
+ // Waterfall
1193
+ const wfCell = document.createElement('div');
1194
+ wfCell.className = 'net-cell net-waterfall';
1195
+ wfCell.dataset.col = 'waterfall';
1196
+ wfCell.style.width = NET_COLS[6].width + 'px';
1197
+ if (r.ts) {
1198
+ const left = ((r.ts - wfMin) / wfRange) * 100;
1199
+ const width = Math.max(2, ((r.duration || 50) / wfRange) * 100);
1200
+ let barCls = 'pending';
1201
+ if (r.phase === 'error') barCls = 'err';
1202
+ else if (r.status) barCls = `s${Math.floor(r.status/100)}`;
1203
+ wfCell.innerHTML = `<div class="wf-bar ${barCls}" style="left:${left}%;width:${width}%"></div>`;
1204
+ }
1205
+ row.appendChild(wfCell);
1206
+
1207
+ // Click to select and show detail
1208
+ row.addEventListener('click', () => selectNetRequest(r.id));
1209
+
1210
+ // Right-click for context menu (copy as cURL)
1211
+ row.addEventListener('contextmenu', (e) => {
1212
+ e.preventDefault();
1213
+ showNetContextMenu(e, r);
1214
+ });
1215
+
1216
+ return row;
1217
+ }
1218
+
1219
+ // ─── Select request → overlay detail pane over Status/Type/etc columns ───────
1220
+ function selectNetRequest(id) {
1221
+ state.network.selectedId = id;
1222
+ const r = state.network.requests[id];
1223
+ if (!r) return;
1224
+
1225
+ // Highlight selected row
1226
+ document.querySelectorAll('#netRows .net-row').forEach(el =>
1227
+ el.classList.toggle('selected', el.dataset.id === id)
1228
+ );
1229
+
1230
+ // Position detail pane to overlay everything after the Name column
1231
+ const pane = $('netDetailPane');
1232
+ const nameColWidth = NET_COLS[0].width;
1233
+ pane.style.left = (nameColWidth + 1) + 'px'; // +1 for the border
1234
+ pane.classList.add('open');
1235
+ r._tab = r._tab || 'headers';
1236
+ renderNetDetailTabs(r);
1237
+ renderNetDetailContent(r);
1238
+ }
1239
+
1240
+ function closeNetDetail() {
1241
+ state.network.selectedId = null;
1242
+ const pane = $('netDetailPane');
1243
+ if (pane) pane.classList.remove('open');
1244
+ document.querySelectorAll('#netRows .net-row').forEach(el =>
1245
+ el.classList.remove('selected')
1246
+ );
1247
+ }
1248
+
1249
+ function renderNetDetailTabs(r) {
1250
+ const tabs = $('netDetailTabs');
1251
+ tabs.innerHTML = '';
1252
+ ['Headers', 'Request', 'Preview', 'Response'].forEach(label => {
1253
+ const key = label.toLowerCase();
1254
+ const btn = document.createElement('button');
1255
+ btn.className = 'detail-tab' + (r._tab === key ? ' active' : '');
1256
+ btn.textContent = label;
1257
+ btn.addEventListener('click', () => {
1258
+ r._tab = key;
1259
+ tabs.querySelectorAll('.detail-tab').forEach(b => b.classList.remove('active'));
1260
+ btn.classList.add('active');
1261
+ renderNetDetailContent(r);
1262
+ });
1263
+ tabs.appendChild(btn);
1264
+ });
1265
+ }
1266
+
1267
+ function renderNetDetailContent(r) {
1268
+ const body = $('netDetailContent');
1269
+ if (!body) return;
1270
+ const tab = r._tab || 'headers';
1271
+
1272
+ if (tab === 'headers') {
1273
+ const rqH = r.requestHeaders || {};
1274
+ const rsH = r.responseHeaders || {};
1275
+ const renderH = (title, h) => {
1276
+ const keys = Object.keys(h);
1277
+ if (!keys.length) return `<div class="section-label">${title}</div><span style="color:var(--text-dim)">none</span>`;
1278
+ return `<div class="section-label">${title}</div><div class="kv-grid">${keys.map(k => {
1279
+ let val = h[k];
1280
+ if (val && typeof val === 'object') { try { val = JSON.stringify(val); } catch { val = String(val); } }
1281
+ return `<span class="kv-key">${esc(k)}</span><span class="kv-val">${esc(val)}</span>`;
1282
+ }).join('')}</div>`;
1283
+ };
1284
+ body.innerHTML = `<div class="section-label" style="margin-top:0">General</div>
1285
+ <div class="kv-grid">
1286
+ <span class="kv-key">Request URL</span><span class="kv-val">${esc(r.url)}</span>
1287
+ <span class="kv-key">Method</span><span class="kv-val">${esc(r.method)}</span>
1288
+ <span class="kv-key">Status</span><span class="kv-val ${r.status ? 's-' + Math.floor(r.status/100) : 's-pending'}">${r.status || 'Pending'} ${r.statusText || ''}</span>
1289
+ </div>
1290
+ ${renderH('Response Headers', rsH)}
1291
+ ${renderH('Request Headers', rqH)}`;
1292
+ } else if (tab === 'request') {
1293
+ if (!r.requestBody) {
1294
+ body.innerHTML = '<span style="color:var(--text-dim)">No request body</span>';
1295
+ } else {
1296
+ body.innerHTML = '';
1297
+ let reqData = r.requestBody;
1298
+ if (typeof reqData === 'string') {
1299
+ try { reqData = JSON.parse(reqData); } catch {}
1300
+ }
1301
+ if (reqData && typeof reqData === 'object') {
1302
+ body.appendChild(createTreeNode(null, reqData, false));
1303
+ body.addEventListener('contextmenu', (e) => {
1304
+ e.preventDefault();
1305
+ showPreviewCopyMenu(e, reqData);
1306
+ });
1307
+ } else {
1308
+ body.innerHTML = renderJSON(r.requestBody);
1309
+ }
1310
+ }
1311
+ } else if (tab === 'preview') {
1312
+ if (r.phase === 'error') { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
1313
+ if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
1314
+ // Render as collapsible JSON tree with right-click copy
1315
+ const val = r.responseBody;
1316
+ let treeData = val;
1317
+ if (typeof val === 'string') {
1318
+ try { treeData = JSON.parse(val); } catch { body.textContent = val; return; }
1319
+ }
1320
+ if (treeData && typeof treeData === 'object') {
1321
+ body.innerHTML = '';
1322
+ body.appendChild(createTreeNode(null, treeData, false));
1323
+ // Right-click on preview to copy the whole object or clicked node value
1324
+ body.addEventListener('contextmenu', (e) => {
1325
+ e.preventDefault();
1326
+ showPreviewCopyMenu(e, treeData);
1327
+ });
1328
+ } else {
1329
+ body.innerHTML = '<span style="color:var(--text-dim)">No preview available</span>';
1330
+ }
1331
+ } else if (tab === 'response') {
1332
+ if (r.phase === 'error') { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
1333
+ if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
1334
+ body.innerHTML = renderJSON(r.responseBody);
1335
+ }
1336
+ }
1337
+
1338
+ // ─── Network context menus ──────────────────────────────────────────────────
1339
+ function showNetContextMenu(e, r) {
1340
+ const items = [
1341
+ { label: 'Copy as cURL', action: () => navigator.clipboard.writeText(buildCurlCommand(r)) },
1342
+ { label: 'Copy URL', action: () => navigator.clipboard.writeText(r.url || '') },
1343
+ ];
1344
+ if (r.responseBody) {
1345
+ items.push({ label: 'Copy Response', action: () => {
1346
+ const text = typeof r.responseBody === 'string' ? r.responseBody : JSON.stringify(r.responseBody, null, 2);
1347
+ navigator.clipboard.writeText(text);
1348
+ }});
1349
+ }
1350
+ showContextMenu(e, items);
1351
+ }
1352
+
1353
+ function showPreviewCopyMenu(e, fullData) {
1354
+ const items = [
1355
+ { label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(fullData, null, 2)) },
1356
+ ];
1357
+ const sel = window.getSelection();
1358
+ if (sel && sel.toString().length > 0) {
1359
+ items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
1360
+ }
1361
+ const keyEl = e.target.closest('.ov-key');
1362
+ const leafEl = e.target.closest('.ov-leaf');
1363
+ if (keyEl || leafEl) {
1364
+ items.push({ label: 'Copy Value', action: () => navigator.clipboard.writeText((leafEl || keyEl.parentElement).textContent) });
1365
+ }
1366
+ showContextMenu(e, items);
1367
+ }
1368
+
1369
+ function buildCurlCommand(r) {
1370
+ let cmd = `curl '${r.url}'`;
1371
+ if (r.method && r.method !== 'GET') cmd += ` -X ${r.method}`;
1372
+ const headers = r.requestHeaders || {};
1373
+ Object.entries(headers).forEach(([k, v]) => {
1374
+ cmd += ` \\\n -H '${k}: ${v}'`;
1375
+ });
1376
+ if (r.requestBody) {
1377
+ const body = typeof r.requestBody === 'string' ? r.requestBody : JSON.stringify(r.requestBody);
1378
+ cmd += ` \\\n --data-raw '${body.replace(/'/g, "'\\''")}'`;
1379
+ }
1380
+ return cmd;
1381
+ }
1382
+
1383
+ // ─────────────────────────────────────────────────────────────────────────────
1384
+ // REDUX PANEL
1385
+ // ─────────────────────────────────────────────────────────────────────────────
1386
+ function initReduxPanel() {
1387
+ const panel = $('panel-redux');
1388
+ panel.innerHTML = `
1389
+ <div class="panel-toolbar">
1390
+ <span class="panel-label">Redux</span>
1391
+ <span class="badge" id="rBadge">0</span>
1392
+ <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
1393
+ <input id="reduxSearch" class="net-search-input" placeholder="Filter actions..." />
1394
+ <div class="time-travel-bar" style="border:none;padding:0;margin:0">
1395
+ <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected-1)">◀</button>
1396
+ <span class="tt-label" id="ttLabel">—/—</span>
1397
+ <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected+1)">▶</button>
1398
+ </div>
1399
+ </div>
1400
+ </div>
1401
+ <div class="scroll-area" id="reduxContent">
1402
+ <div class="empty-state" id="reduxEmpty">
1403
+ <div class="icon">🔲</div>
1404
+ <div class="label">No actions dispatched</div>
1405
+ <div class="hint">Connect Redux store to RNDebugSDK</div>
1406
+ </div>
1407
+ </div>`;
1408
+
1409
+ $('reduxSearch').addEventListener('input', (e) => {
1410
+ state.redux.searchFilter = e.target.value.toLowerCase().trim();
1411
+ renderRedux();
1412
+ });
1413
+ }
1414
+
1415
+ window.reduxJumpTo = idx => {
1416
+ const { actions } = state.redux;
1417
+ if (!actions.length) return;
1418
+ idx = Math.max(0, Math.min(actions.length - 1, idx));
1419
+ state.redux.selected = idx;
1420
+ renderRedux();
1421
+ };
1422
+
1423
+ // Fast deep equality check for Redux state comparison
1424
+ function _deepEqual(a, b) {
1425
+ if (a === b) return true;
1426
+ if (a == null || b == null) return false;
1427
+ if (typeof a !== typeof b) return false;
1428
+ if (typeof a !== 'object') return false;
1429
+ try {
1430
+ return JSON.stringify(a) === JSON.stringify(b);
1431
+ } catch { return false; }
1432
+ }
1433
+
1434
+ function handleReduxEvent(event) {
1435
+ if (event.type !== 'redux') return;
1436
+ const { action, nextState } = event;
1437
+ const idx = state.redux.actions.length;
1438
+
1439
+ const prevState = state.redux.states.length > 0 ? state.redux.states[state.redux.states.length - 1] : null;
1440
+ const changedKeys = [];
1441
+ if (prevState && nextState && typeof prevState === 'object' && typeof nextState === 'object') {
1442
+ const allKeys = new Set([...Object.keys(prevState), ...Object.keys(nextState)]);
1443
+ allKeys.forEach(k => { if (!_deepEqual(prevState[k], nextState[k])) changedKeys.push(k); });
1444
+ }
1445
+
1446
+ state.redux.actions.push({ type: action?.type || '?', payload: action, ts: event.ts, index: idx, changedKeys });
1447
+ state.redux.states.push(nextState);
1448
+ state.redux.selected = idx;
1449
+ $('rBadge').textContent = state.redux.actions.length;
1450
+ renderRedux();
1451
+ }
1452
+
1453
+ function renderRedux() {
1454
+ const content = $('reduxContent');
1455
+ const empty = $('reduxEmpty');
1456
+ if (!content) return;
1457
+
1458
+ const { actions, states, selected, searchFilter } = state.redux;
1459
+ const visible = searchFilter ? actions.filter(a => a.type.toLowerCase().includes(searchFilter)) : actions;
1460
+
1461
+ empty.style.display = visible.length ? 'none' : 'flex';
1462
+ content.querySelectorAll('.rdx-entry').forEach(e => e.remove());
1463
+ if (!visible.length) return;
1464
+
1465
+ const ttLabel = $('ttLabel');
1466
+ if (ttLabel) ttLabel.textContent = `${selected + 1}/${actions.length}`;
1467
+
1468
+ const frag = document.createDocumentFragment();
1469
+ visible.forEach(a => {
1470
+ const isSelected = a.index === selected;
1471
+ const isPrev = a.index === selected - 1;
1472
+ const isNext = a.index === selected + 1;
1473
+
1474
+ const entry = document.createElement('div');
1475
+ entry.className = 'rdx-entry' + (isSelected ? ' selected' : '') + (isPrev ? ' is-prev' : '') + (isNext ? ' is-next' : '');
1476
+
1477
+ // Row header — always visible
1478
+ const header = document.createElement('div');
1479
+ header.className = 'rdx-entry-header';
1480
+ const changesBadge = a.changedKeys?.length ? `<span class="rdx-changes">${a.changedKeys.length}</span>` : '';
1481
+ const roleTag = isPrev ? '<span class="rdx-role prev">PREV</span>' : isNext ? '<span class="rdx-role next">NEXT</span>' : isSelected ? '<span class="rdx-role current">CURRENT</span>' : '';
1482
+ header.innerHTML = `<span class="rdx-index">#${a.index}</span>${roleTag}<span class="rdx-type">${esc(a.type)}</span>${changesBadge}<span class="rdx-time">${ts(a.ts)}</span>`;
1483
+ header.addEventListener('click', () => { state.redux.selected = a.index; renderRedux(); });
1484
+ entry.appendChild(header);
1485
+
1486
+ // Expanded detail for selected / prev / next
1487
+ if (isSelected || isPrev || isNext) {
1488
+ const detail = document.createElement('div');
1489
+ detail.className = 'rdx-entry-detail';
1490
+
1491
+ // Changed keys badges
1492
+ if (a.changedKeys?.length > 0) {
1493
+ const keysEl = document.createElement('div');
1494
+ keysEl.className = 'redux-changed-keys';
1495
+ keysEl.innerHTML = `<span class="redux-changed-label">Changed:</span> ${a.changedKeys.map(k =>
1496
+ `<span class="redux-changed-key">${esc(k)}</span>`).join(' ')}`;
1497
+ detail.appendChild(keysEl);
1498
+ }
1499
+
1500
+ // Payload
1501
+ if (a.payload) {
1502
+ const pLabel = document.createElement('div');
1503
+ pLabel.className = 'redux-section-title';
1504
+ pLabel.textContent = 'Payload';
1505
+ detail.appendChild(pLabel);
1506
+ detail.appendChild(createTreeNode(null, a.payload, !isSelected));
1507
+ }
1508
+
1509
+ // Store changes (only for selected)
1510
+ if (isSelected) {
1511
+ const prevS = a.index > 0 ? states[a.index - 1] : null;
1512
+ const currS = states[a.index];
1513
+ if (currS && typeof currS === 'object' && a.changedKeys?.length > 0) {
1514
+ const sLabel = document.createElement('div');
1515
+ sLabel.className = 'redux-section-title';
1516
+ sLabel.textContent = 'Store Changes';
1517
+ detail.appendChild(sLabel);
1518
+
1519
+ a.changedKeys.forEach(key => {
1520
+ const keyWrap = document.createElement('div');
1521
+ keyWrap.className = 'rdx-store-diff';
1522
+ const kLabel = document.createElement('div');
1523
+ kLabel.className = 'rdx-store-key-label';
1524
+ kLabel.textContent = key;
1525
+ keyWrap.appendChild(kLabel);
1526
+
1527
+ if (prevS && prevS[key] !== undefined) {
1528
+ const prevRow = document.createElement('div');
1529
+ prevRow.className = 'rdx-diff-row removed';
1530
+ prevRow.innerHTML = '<span class="rdx-diff-sign">-</span>';
1531
+ prevRow.appendChild(createTreeNode(null, prevS[key], true));
1532
+ keyWrap.appendChild(prevRow);
1533
+ }
1534
+ if (currS[key] !== undefined) {
1535
+ const newRow = document.createElement('div');
1536
+ newRow.className = 'rdx-diff-row added';
1537
+ newRow.innerHTML = '<span class="rdx-diff-sign">+</span>';
1538
+ newRow.appendChild(createTreeNode(null, currS[key], true));
1539
+ keyWrap.appendChild(newRow);
1540
+ }
1541
+ detail.appendChild(keyWrap);
1542
+ });
1543
+ }
1544
+ }
1545
+
1546
+ entry.appendChild(detail);
1547
+ }
1548
+
1549
+ frag.appendChild(entry);
1550
+ });
1551
+
1552
+ content.appendChild(frag);
1553
+ const selEl = content.querySelector('.rdx-entry.selected');
1554
+ if (selEl) selEl.scrollIntoView({ block: 'nearest' });
1555
+ }
1556
+
1557
+ // ─────────────────────────────────────────────────────────────────────────────
1558
+ // ASYNC STORAGE PANEL
1559
+ // ─────────────────────────────────────────────────────────────────────────────
1560
+ function initStoragePanel() {
1561
+ const panel = $('panel-storage');
1562
+ panel.innerHTML = `
1563
+ <div class="panel-toolbar">
1564
+ <span class="panel-label">AsyncStorage</span>
1565
+ <span class="badge" id="sBadge">0</span>
1566
+ <div class="ml-auto">
1567
+ <input id="storageSearch" class="net-search-input" placeholder="Filter keys..." />
1568
+ </div>
1569
+ </div>
1570
+ <div class="storage-layout">
1571
+ <div class="storage-keys">
1572
+ <div class="panel-toolbar" style="height:32px">
1573
+ <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Keys</span>
1574
+ </div>
1575
+ <div class="scroll-area storage-keys-list" id="storageKeyList">
1576
+ <div class="empty-state" id="storageEmpty">
1577
+ <div class="icon">💾</div>
1578
+ <div class="label">No storage data</div>
1579
+ <div class="hint">Add storage plugin to RNDebugPlugin</div>
1580
+ </div>
1581
+ </div>
1582
+ </div>
1583
+ <div class="storage-value-view">
1584
+ <div class="storage-value-toolbar">
1585
+ <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Value</span>
1586
+ <span id="storageSelectedKey" style="font-size:11px;color:var(--accent);margin-left:8px"></span>
1587
+ </div>
1588
+ <div class="storage-value-body" id="storageValueBody">
1589
+ <span style="color:var(--text-dim)">Select a key to view its value</span>
1590
+ </div>
1591
+ </div>
1592
+ </div>`;
1593
+
1594
+ $('storageSearch').addEventListener('input', (e) => {
1595
+ state.storage.searchFilter = e.target.value.toLowerCase().trim();
1596
+ renderStorage();
1597
+ });
1598
+ }
1599
+
1600
+ let _storageRAF = null;
1601
+
1602
+ function handleStorageEvent(event) {
1603
+ if (event.type !== 'storage') return;
1604
+ const { key, value, action } = event;
1605
+ if (action === 'set' || action === 'snapshot') {
1606
+ if (action === 'snapshot' && typeof key === 'object') {
1607
+ // Skip if data hasn't changed
1608
+ const newKeys = Object.keys(key).slice().sort().join(',');
1609
+ const oldKeys = state.storage.keys.slice().sort().join(',');
1610
+ if (newKeys === oldKeys) {
1611
+ // Check if values changed
1612
+ let same = true;
1613
+ for (const [k, v] of Object.entries(key)) {
1614
+ if (state.storage.entries[k] !== v) { same = false; break; }
1615
+ }
1616
+ if (same) return; // No changes, skip re-render
1617
+ }
1618
+ Object.entries(key).forEach(([k, v]) => {
1619
+ state.storage.entries[k] = v;
1620
+ if (!state.storage.keys.includes(k)) state.storage.keys.push(k);
1621
+ });
1622
+ } else {
1623
+ if (state.storage.entries[key] === value) return; // No change
1624
+ state.storage.entries[key] = value;
1625
+ if (!state.storage.keys.includes(key)) state.storage.keys.push(key);
1626
+ }
1627
+ } else if (action === 'remove') {
1628
+ if (!(key in state.storage.entries)) return; // Already removed
1629
+ delete state.storage.entries[key];
1630
+ state.storage.keys = state.storage.keys.filter(k => k !== key);
1631
+ if (state.storage.selected === key) state.storage.selected = null;
1632
+ }
1633
+ $('sBadge').textContent = state.storage.keys.length;
1634
+ // Debounce render via rAF
1635
+ if (!_storageRAF) {
1636
+ _storageRAF = requestAnimationFrame(() => {
1637
+ _storageRAF = null;
1638
+ renderStorage();
1639
+ });
1640
+ }
1641
+ }
1642
+
1643
+ function renderStorage() {
1644
+ const list = $('storageKeyList');
1645
+ const empty = $('storageEmpty');
1646
+ if (!list) return;
1647
+
1648
+ const { searchFilter } = state.storage;
1649
+ const visible = state.storage.keys.filter(k =>
1650
+ !searchFilter || k.toLowerCase().includes(searchFilter)
1651
+ );
1652
+
1653
+ empty.style.display = visible.length ? 'none' : 'flex';
1654
+ list.querySelectorAll('.storage-key-row').forEach(e => e.remove());
1655
+
1656
+ const frag = document.createDocumentFragment();
1657
+ visible.forEach(k => {
1658
+ const div = document.createElement('div');
1659
+ const val = state.storage.entries[k] || '';
1660
+ div.className = 'storage-key-row entry' + (k === state.storage.selected ? ' selected' : '');
1661
+ div.innerHTML = `
1662
+ <span class="key-name">${highlight(esc(k), searchFilter)}</span>
1663
+ <span class="key-size">${formatSize(val.length)}</span>`;
1664
+ div.onclick = () => { state.storage.selected = k; renderStorage(); renderStorageValue(); };
1665
+ frag.appendChild(div);
1666
+ });
1667
+ list.appendChild(frag);
1668
+ renderStorageValue();
1669
+ }
1670
+
1671
+ function renderStorageValue() {
1672
+ const body = $('storageValueBody');
1673
+ const keyLabel = $('storageSelectedKey');
1674
+ if (!body) return;
1675
+ const { selected, entries } = state.storage;
1676
+ if (!selected) {
1677
+ body.innerHTML = '<span style="color:var(--text-dim)">Select a key</span>';
1678
+ if (keyLabel) keyLabel.textContent = '';
1679
+ return;
1680
+ }
1681
+ if (keyLabel) keyLabel.textContent = selected;
1682
+ body.innerHTML = renderJSON(entries[selected]);
1683
+ }
1684
+
1685
+ function formatSize(bytes) {
1686
+ if (bytes < 1024) return `${bytes}b`;
1687
+ return `${(bytes/1024).toFixed(1)}kb`;
1688
+ }
1689
+
1690
+ // ─────────────────────────────────────────────────────────────────────────────
1691
+ // REACT TREE PANEL
1692
+ // ─────────────────────────────────────────────────────────────────────────────
1693
+ function initReactPanel() {
1694
+ const panel = $('panel-react');
1695
+ panel.innerHTML = `
1696
+ <div class="panel-toolbar">
1697
+ <span class="panel-label">React Tree</span>
1698
+ </div>
1699
+ <div class="react-panel-inner">
1700
+ <div class="react-connect-hint" id="reactHint">
1701
+ <div class="icon" style="font-size:40px;opacity:.2">⚛️</div>
1702
+ <div class="label">React DevTools</div>
1703
+ <div class="hint">Launches as a separate window connected to your app</div>
1704
+ <div class="hint">React Native auto-connects on port <code>8097</code> in dev mode</div>
1705
+ <button class="btn-launch" id="btnReactDT">Open React DevTools ↗</button>
1706
+ </div>
1707
+ </div>`;
1708
+
1709
+ $('btnReactDT').addEventListener('click', () => {
1710
+ window.electronAPI?.openReactDevTools();
1711
+ });
1712
+ }
1713
+
1714
+ // ─────────────────────────────────────────────────────────────────────────────
1715
+ // SETTINGS PANEL
1716
+ // ─────────────────────────────────────────────────────────────────────────────
1717
+ function getStoredTheme() {
1718
+ try { return localStorage.getItem('rn-debug-theme') || 'dark'; } catch { return 'dark'; }
1719
+ }
1720
+ function setStoredTheme(t) {
1721
+ try { localStorage.setItem('rn-debug-theme', t); } catch {}
1722
+ }
1723
+ function getStoredFontSize() {
1724
+ try { return parseInt(localStorage.getItem('rn-debug-fontsize')) || 12; } catch { return 12; }
1725
+ }
1726
+ function setStoredFontSize(s) {
1727
+ try { localStorage.setItem('rn-debug-fontsize', String(s)); } catch {}
1728
+ }
1729
+
1730
+ function getStoredAppName() {
1731
+ try { return localStorage.getItem('rn-debug-appname') || 'ReactoRadar'; } catch { return 'ReactoRadar'; }
1732
+ }
1733
+ function setStoredAppName(n) {
1734
+ try { localStorage.setItem('rn-debug-appname', n); } catch {}
1735
+ }
1736
+ function applyAppName(name) {
1737
+ const logo = document.querySelector('.logo');
1738
+ if (logo) {
1739
+ // Split name — first part normal, last word in accent span
1740
+ const words = name.split(/(?=[A-Z])/);
1741
+ if (words.length >= 2) {
1742
+ logo.innerHTML = words.slice(0, -1).join('') + '<span>' + words[words.length - 1] + '</span>';
1743
+ } else {
1744
+ logo.textContent = name;
1745
+ }
1746
+ }
1747
+ document.title = name;
1748
+ }
1749
+
1750
+ function applyTheme(theme) {
1751
+ document.documentElement.setAttribute('data-theme', theme);
1752
+ // Tell main process (light themes need light nativeTheme for window chrome)
1753
+ const isLight = ['light', 'solarized-light'].includes(theme);
1754
+ window.electronAPI?.setTheme(isLight ? 'light' : 'dark');
1755
+ }
1756
+
1757
+ function applyFontSize(size) {
1758
+ document.documentElement.style.setProperty('--app-font-size', size + 'px');
1759
+ document.body.style.fontSize = size + 'px';
1760
+ // Inject/update a <style> tag so ALL current and future elements get the size
1761
+ let styleEl = document.getElementById('dynamic-font-size');
1762
+ if (!styleEl) {
1763
+ styleEl = document.createElement('style');
1764
+ styleEl.id = 'dynamic-font-size';
1765
+ document.head.appendChild(styleEl);
1766
+ }
1767
+ styleEl.textContent = `
1768
+ .log-preview, .log-body, .log-text, .log-caller-inline,
1769
+ .net-cell, .net-cell-name, .net-type, .net-initiator, .net-size, .net-time, .net-status,
1770
+ .detail-content, .kv-val, .kv-key,
1771
+ .rdx-type, .rdx-entry-detail, .rdx-store-key-label,
1772
+ .storage-value-body, .storage-key-row,
1773
+ .sources-code, .source-line-code,
1774
+ .ov-leaf, .ov-key, .ov-preview, .ov-str, .ov-num, .ov-bool, .ov-null, .ov-undef,
1775
+ .perf-meter-label,
1776
+ .settings-label, .settings-hint {
1777
+ font-size: ${size}px !important;
1778
+ }
1779
+ `;
1780
+ const display = $('fontSizeDisplay');
1781
+ if (display) display.textContent = size + 'px';
1782
+ }
1783
+
1784
+ function initSettingsPanel() {
1785
+ const panel = $('panel-settings');
1786
+ const current = getStoredTheme();
1787
+ const currentSize = getStoredFontSize();
1788
+ panel.innerHTML = `
1789
+ <div class="panel-toolbar">
1790
+ <span class="panel-label">Settings</span>
1791
+ </div>
1792
+ <div class="scroll-area">
1793
+ <div class="settings-content">
1794
+ <div class="settings-section">
1795
+ <div class="settings-section-title">Appearance</div>
1796
+ <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
1797
+ <div>
1798
+ <div class="settings-label">Theme</div>
1799
+ <div class="settings-hint">Choose a color theme for the debugger</div>
1800
+ </div>
1801
+ <div class="theme-grid" id="themeSwitcher"></div>
1802
+ </div>
1803
+ <div class="settings-row">
1804
+ <div>
1805
+ <div class="settings-label">Font Size</div>
1806
+ <div class="settings-hint">Adjust text size across all panels</div>
1807
+ </div>
1808
+ <div class="font-size-control">
1809
+ <button class="font-size-btn" id="fontSizeDown">A-</button>
1810
+ <span class="font-size-display" id="fontSizeDisplay">${currentSize}px</span>
1811
+ <button class="font-size-btn" id="fontSizeUp">A+</button>
1812
+ </div>
1813
+ </div>
1814
+ <div class="settings-row">
1815
+ <div>
1816
+ <div class="settings-label">App Name</div>
1817
+ <div class="settings-hint">Customize the app title (visible in titlebar)</div>
1818
+ </div>
1819
+ <div style="display:flex;align-items:center;gap:6px">
1820
+ <input id="appNameInput" class="net-search-input" style="width:140px;text-align:center" value="${getStoredAppName()}" />
1821
+ <button class="font-size-btn" id="appNameReset" title="Reset to default">Reset</button>
1822
+ </div>
1823
+ </div>
1824
+ </div>
1825
+ <div class="settings-section">
1826
+ <div class="settings-section-title">Connection</div>
1827
+ <div class="settings-row">
1828
+ <div>
1829
+ <div class="settings-label">Bridge Ports</div>
1830
+ <div class="settings-hint">Redux :9090 &middot; Storage :9091 &middot; Network :9092 &middot; React DT :8097</div>
1831
+ </div>
1832
+ </div>
1833
+ <div class="settings-row">
1834
+ <div>
1835
+ <div class="settings-label">Metro Bundler</div>
1836
+ <div class="settings-hint">CDP target discovery on :8081</div>
1837
+ </div>
1838
+ </div>
1839
+ </div>
1840
+ <div class="settings-section">
1841
+ <div class="settings-section-title">Keyboard Shortcuts</div>
1842
+ <div class="settings-row">
1843
+ <div class="settings-label">Clear Active Tab</div>
1844
+ <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">Clear button</div>
1845
+ </div>
1846
+ <div class="settings-row">
1847
+ <div class="settings-label">Clear All</div>
1848
+ <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;K</div>
1849
+ </div>
1850
+ <div class="settings-row">
1851
+ <div class="settings-label">Open JS Debugger</div>
1852
+ <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;D</div>
1853
+ </div>
1854
+ <div class="settings-row">
1855
+ <div class="settings-label">Open React DevTools</div>
1856
+ <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;R</div>
1857
+ </div>
1858
+ <div class="settings-row">
1859
+ <div class="settings-label">Toggle Theme</div>
1860
+ <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;&#8679;T</div>
1861
+ </div>
1862
+ <div class="settings-row">
1863
+ <div class="settings-label">Zoom In / Out</div>
1864
+ <div class="settings-hint" style="font-size:11px;color:var(--text-mid)">&#8984;+ / &#8984;-</div>
1865
+ </div>
1866
+ </div>
1867
+ <div class="settings-section">
1868
+ <div class="settings-section-title">How to Use</div>
1869
+ <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
1870
+ <div class="settings-hint" style="line-height:1.8">
1871
+ <b style="color:var(--text)">1. Setup</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar setup</code> from your RN project<br/>
1872
+ <b style="color:var(--text)">2. Start</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar</code> or open ReactoRadar.app<br/>
1873
+ <b style="color:var(--text)">3. Run your app</b> — <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx react-native start --reset-cache</code><br/>
1874
+ <b style="color:var(--text)">4. Debug</b> — Console, Network, Redux data flows automatically<br/>
1875
+ <b style="color:var(--text)">5. Remove</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar remove</code> to clean uninstall
1876
+ </div>
1877
+ </div>
1878
+ </div>
1879
+ <div class="settings-section">
1880
+ <div class="settings-section-title">About</div>
1881
+ <div class="settings-about">
1882
+ <div class="about-name" id="aboutAppName">${getStoredAppName()}</div>
1883
+ <div class="about-version">v1.2.0</div>
1884
+ <div class="about-desc">A standalone macOS debugger for React Native apps.<br/>Supports Hermes, New Architecture, and React Native 0.74+.</div>
1885
+ <div class="about-links" style="display:flex;gap:16px;justify-content:center">
1886
+ <span class="about-link" id="linkGithub">GitHub</span>
1887
+ <span class="about-link" id="linkDocs">Documentation</span>
1888
+ </div>
1889
+ </div>
1890
+ </div>
1891
+ </div>
1892
+ </div>`;
1893
+
1894
+ // Build theme cards
1895
+ const themes = [
1896
+ { id: 'dark', name: 'Dark', colors: ['#0d0e11','#4facff','#3dd68c','#ff5e72'] },
1897
+ { id: 'light', name: 'Light', colors: ['#f5f6f8','#0969da','#1a7f37','#cf222e'] },
1898
+ { id: 'monokai', name: 'Monokai', colors: ['#272822','#66d9ef','#a6e22e','#f92672'] },
1899
+ { id: 'dracula', name: 'Dracula', colors: ['#282a36','#8be9fd','#50fa7b','#ff5555'] },
1900
+ { id: 'solarized-dark', name: 'Solarized Dark', colors: ['#002b36','#268bd2','#859900','#dc322f'] },
1901
+ { id: 'solarized-light', name: 'Solarized Light', colors: ['#fdf6e3','#268bd2','#859900','#dc322f'] },
1902
+ { id: 'nord', name: 'Nord', colors: ['#2e3440','#88c0d0','#a3be8c','#bf616a'] },
1903
+ { id: 'github-dark', name: 'GitHub Dark', colors: ['#0d1117','#58a6ff','#3fb950','#f85149'] },
1904
+ { id: 'one-dark', name: 'One Dark', colors: ['#282c34','#61afef','#98c379','#e06c75'] },
1905
+ ];
1906
+ const grid = $('themeSwitcher');
1907
+ themes.forEach(t => {
1908
+ const btn = document.createElement('button');
1909
+ btn.className = 'theme-card' + (current === t.id ? ' active' : '');
1910
+ btn.dataset.theme = t.id;
1911
+ btn.innerHTML = '<div class="theme-preview" style="background:' + t.colors[0] + '">' +
1912
+ '<span style="background:' + t.colors[1] + '"></span>' +
1913
+ '<span style="background:' + t.colors[2] + '"></span>' +
1914
+ '<span style="background:' + t.colors[3] + '"></span>' +
1915
+ '</div><div class="theme-name">' + t.name + '</div>';
1916
+ grid.appendChild(btn);
1917
+ });
1918
+
1919
+ // Theme switcher
1920
+ $('themeSwitcher').addEventListener('click', (e) => {
1921
+ const btn = e.target.closest('.theme-card');
1922
+ if (!btn) return;
1923
+ const theme = btn.dataset.theme;
1924
+ document.querySelectorAll('#themeSwitcher .theme-card').forEach(b => b.classList.remove('active'));
1925
+ btn.classList.add('active');
1926
+ setStoredTheme(theme);
1927
+ applyTheme(theme);
1928
+ });
1929
+
1930
+ // About links
1931
+ $('linkGithub')?.addEventListener('click', () => {
1932
+ window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger');
1933
+ });
1934
+ $('linkDocs')?.addEventListener('click', () => {
1935
+ window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger#readme');
1936
+ });
1937
+
1938
+ // App name
1939
+ $('appNameInput').addEventListener('change', (e) => {
1940
+ const name = e.target.value.trim() || 'ReactoRadar';
1941
+ setStoredAppName(name);
1942
+ applyAppName(name);
1943
+ });
1944
+ $('appNameReset').addEventListener('click', () => {
1945
+ setStoredAppName('ReactoRadar');
1946
+ $('appNameInput').value = 'ReactoRadar';
1947
+ applyAppName('ReactoRadar');
1948
+ });
1949
+
1950
+ // Font size controls
1951
+ $('fontSizeDown').addEventListener('click', () => {
1952
+ let size = getStoredFontSize();
1953
+ size = Math.max(8, size - 1);
1954
+ setStoredFontSize(size);
1955
+ applyFontSize(size);
1956
+ });
1957
+ $('fontSizeUp').addEventListener('click', () => {
1958
+ let size = getStoredFontSize();
1959
+ size = Math.min(20, size + 1);
1960
+ setStoredFontSize(size);
1961
+ applyFontSize(size);
1962
+ });
1963
+ }
1964
+
1965
+ // Apply saved theme + font size + app name on load
1966
+ applyTheme(getStoredTheme());
1967
+ applyFontSize(getStoredFontSize());
1968
+ applyAppName(getStoredAppName());
1969
+
1970
+ // ─────────────────────────────────────────────────────────────────────────────
1971
+ // SOURCES PANEL — CDP-based file browser + breakpoints
1972
+ // ─────────────────────────────────────────────────────────────────────────────
1973
+ function initSourcesPanel() {
1974
+ const panel = $('panel-sources');
1975
+ panel.innerHTML = `
1976
+ <div class="panel-toolbar">
1977
+ <span class="panel-label">Sources</span>
1978
+ <div class="ml-auto" style="display:flex;gap:6px">
1979
+ <button class="tb-btn" id="btnOpenSourcesExt" title="Open in separate DevTools window">Breakpoints ↗</button>
1980
+ </div>
1981
+ </div>
1982
+ <div class="sources-layout">
1983
+ <div class="sources-sidebar" id="sourcesSidebar">
1984
+ <div class="panel-toolbar" style="height:32px">
1985
+ <input id="sourcesSearch" class="net-search-input" style="width:100%" placeholder="Search files..." />
1986
+ </div>
1987
+ <div class="scroll-area sources-file-list" id="sourcesFileList">
1988
+ <div class="empty-state" id="sourcesEmpty">
1989
+ <div class="icon" style="font-size:28px;opacity:.2">&lt;/&gt;</div>
1990
+ <div class="label">Waiting for Metro...</div>
1991
+ <div class="hint">Source files will load when Metro is running</div>
1992
+ </div>
1993
+ </div>
1994
+ </div>
1995
+ <div class="sources-editor" id="sourcesEditor">
1996
+ <div class="panel-toolbar" style="height:32px">
1997
+ <span id="sourcesFileName" style="font-size:10px;color:var(--accent)"></span>
1998
+ <span id="sourcesLineInfo" style="font-size:10px;color:var(--text-dim);margin-left:auto"></span>
1999
+ </div>
2000
+ <div class="scroll-area sources-code" id="sourcesCode">
2001
+ <span style="color:var(--text-dim);padding:20px;display:block">Select a file to view its source</span>
2002
+ </div>
2003
+ </div>
2004
+ </div>`;
2005
+
2006
+ // Open JS Debugger for breakpoints
2007
+ $('btnOpenSourcesExt').addEventListener('click', () => {
2008
+ window.electronAPI?.openCDPTarget(null);
2009
+ });
2010
+
2011
+ // Search filter for file tree
2012
+ $('sourcesSearch').addEventListener('input', (e) => {
2013
+ const term = e.target.value.toLowerCase().trim();
2014
+ document.querySelectorAll('#sourcesFileList .src-tree-file').forEach(row => {
2015
+ const filepath = row.dataset.file || '';
2016
+ const match = !term || filepath.toLowerCase().includes(term);
2017
+ row.style.display = match ? '' : 'none';
2018
+ });
2019
+ // Show/hide folder nodes based on whether they have visible children
2020
+ document.querySelectorAll('#sourcesFileList .src-tree-folder').forEach(folder => {
2021
+ const visibleFiles = folder.querySelectorAll('.src-tree-file:not([style*="display: none"])');
2022
+ folder.style.display = (!term || visibleFiles.length > 0) ? '' : 'none';
2023
+ // Auto-expand folders when searching
2024
+ if (term && visibleFiles.length > 0) {
2025
+ const children = folder.querySelector('.src-tree-children');
2026
+ const arrow = folder.querySelector('.src-tree-arrow');
2027
+ if (children) children.style.display = 'block';
2028
+ if (arrow) { arrow.textContent = '\u25BC'; arrow.classList.add('expanded'); }
2029
+ }
2030
+ });
2031
+ });
2032
+
2033
+ // Fetch the source map / bundle modules list from Metro
2034
+ fetchSourceFileList();
2035
+ }
2036
+
2037
+ async function fetchSourceFileList() {
2038
+ if (!window.electronAPI?.getSourceFileList) {
2039
+ console.log('[Sources] electronAPI.getSourceFileList not available, retrying...');
2040
+ setTimeout(fetchSourceFileList, 5000);
2041
+ return;
2042
+ }
2043
+ try {
2044
+ console.log('[Sources] Fetching file list from Metro...');
2045
+ const result = await window.electronAPI.getSourceFileList();
2046
+ console.log('[Sources] Got result:', result?.files?.length, 'files, root:', result?.root?.slice(-30));
2047
+ if (result?.files && result.files.length > 0) {
2048
+ state._sourcesRoot = result.root;
2049
+ // Limit to 500 files max to avoid DOM overload
2050
+ const files = result.files.length > 500 ? result.files.slice(0, 500) : result.files;
2051
+ renderSourceFileList(files);
2052
+ console.log('[Sources] Rendered', files.length, 'files');
2053
+ } else {
2054
+ console.log('[Sources] No files, retrying in 5s...');
2055
+ setTimeout(fetchSourceFileList, 5000);
2056
+ }
2057
+ } catch (e) {
2058
+ console.log('[Sources] Error:', e?.message || e);
2059
+ setTimeout(fetchSourceFileList, 5000);
2060
+ }
2061
+ }
2062
+
2063
+ function renderSourceFileList(files) {
2064
+ const list = $('sourcesFileList');
2065
+ const empty = $('sourcesEmpty');
2066
+ if (!list) return;
2067
+ if (!files.length) return;
2068
+ if (empty) empty.style.display = 'none';
2069
+ list.querySelectorAll('.src-tree-node').forEach(e => e.remove());
2070
+
2071
+ // Build folder tree from file paths
2072
+ const tree = {};
2073
+ files.forEach(filepath => {
2074
+ const parts = filepath.split('/').filter(Boolean);
2075
+ let node = tree;
2076
+ parts.forEach((part, i) => {
2077
+ if (i === parts.length - 1) {
2078
+ // File leaf
2079
+ node[part] = filepath; // string = file
2080
+ } else {
2081
+ // Folder
2082
+ if (!node[part] || typeof node[part] === 'string') node[part] = {};
2083
+ node = node[part];
2084
+ }
2085
+ });
2086
+ });
2087
+
2088
+ // Render tree recursively
2089
+ const frag = document.createDocumentFragment();
2090
+
2091
+ // Project folders first, node_modules last
2092
+ const topKeys = Object.keys(tree).sort((a, b) => {
2093
+ if (a === 'node_modules') return 1;
2094
+ if (b === 'node_modules') return -1;
2095
+ return a.localeCompare(b);
2096
+ });
2097
+
2098
+ topKeys.forEach(key => {
2099
+ frag.appendChild(buildSourceTreeNode(key, tree[key], 0));
2100
+ });
2101
+ list.appendChild(frag);
2102
+ }
2103
+
2104
+ function buildSourceTreeNode(name, value, depth) {
2105
+ if (typeof value === 'string') {
2106
+ // File leaf
2107
+ const row = document.createElement('div');
2108
+ row.className = 'src-tree-node src-tree-file';
2109
+ row.dataset.file = value;
2110
+ row.style.paddingLeft = (12 + depth * 16) + 'px';
2111
+ const isNM = value.includes('node_modules');
2112
+ const ext = name.split('.').pop();
2113
+ const iconColor = ext === 'tsx' || ext === 'ts' ? '#3178c6'
2114
+ : ext === 'jsx' || ext === 'js' ? '#f0db4f'
2115
+ : ext === 'json' ? '#a0a0a0'
2116
+ : ext === 'css' ? '#264de4'
2117
+ : 'var(--text-dim)';
2118
+ 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>`;
2119
+ row.addEventListener('click', () => {
2120
+ const fileList = $('sourcesFileList');
2121
+ fileList.querySelectorAll('.src-tree-file').forEach(el => el.classList.remove('selected'));
2122
+ row.classList.add('selected');
2123
+ loadSourceFile(value);
2124
+ });
2125
+ // Search filter support
2126
+ const searchInput = $('sourcesSearch');
2127
+ if (searchInput && searchInput.value) {
2128
+ const term = searchInput.value.toLowerCase();
2129
+ if (!name.toLowerCase().includes(term) && !value.toLowerCase().includes(term)) {
2130
+ row.style.display = 'none';
2131
+ }
2132
+ }
2133
+ return row;
2134
+ }
2135
+
2136
+ // Folder node
2137
+ const container = document.createElement('div');
2138
+ container.className = 'src-tree-node src-tree-folder';
2139
+
2140
+ const header = document.createElement('div');
2141
+ header.className = 'src-tree-folder-header';
2142
+ header.style.paddingLeft = (8 + depth * 16) + 'px';
2143
+
2144
+ const arrow = document.createElement('span');
2145
+ arrow.className = 'src-tree-arrow';
2146
+ arrow.textContent = '\u25B6';
2147
+
2148
+ const folderName = document.createElement('span');
2149
+ folderName.className = 'src-folder-name';
2150
+ const isNM = name === 'node_modules';
2151
+ folderName.style.color = isNM ? 'var(--text-dim)' : 'var(--text)';
2152
+ folderName.textContent = name;
2153
+
2154
+ header.appendChild(arrow);
2155
+ header.appendChild(folderName);
2156
+ container.appendChild(header);
2157
+
2158
+ const children = document.createElement('div');
2159
+ children.className = 'src-tree-children';
2160
+ // Start all folders collapsed
2161
+ children.style.display = 'none';
2162
+
2163
+ // Sort: folders first, then files
2164
+ const entries = Object.entries(value).sort((a, b) => {
2165
+ const aIsFolder = typeof a[1] === 'object';
2166
+ const bIsFolder = typeof b[1] === 'object';
2167
+ if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
2168
+ return a[0].localeCompare(b[0]);
2169
+ });
2170
+
2171
+ let populated = false;
2172
+ function populate() {
2173
+ if (populated) return;
2174
+ populated = true;
2175
+ entries.forEach(([childName, childValue]) => {
2176
+ children.appendChild(buildSourceTreeNode(childName, childValue, depth + 1));
2177
+ });
2178
+ }
2179
+
2180
+ if (!startCollapsed) populate();
2181
+
2182
+ header.addEventListener('click', () => {
2183
+ const isOpen = children.style.display !== 'none';
2184
+ if (!isOpen) {
2185
+ populate();
2186
+ children.style.display = 'block';
2187
+ arrow.textContent = '\u25BC';
2188
+ arrow.classList.add('expanded');
2189
+ } else {
2190
+ children.style.display = 'none';
2191
+ arrow.textContent = '\u25B6';
2192
+ arrow.classList.remove('expanded');
2193
+ }
2194
+ });
2195
+
2196
+ container.appendChild(children);
2197
+ return container;
2198
+ }
2199
+
2200
+ async function loadSourceFile(filepath) {
2201
+ const codeEl = $('sourcesCode');
2202
+ const nameEl = $('sourcesFileName');
2203
+ const lineEl = $('sourcesLineInfo');
2204
+ if (!codeEl) return;
2205
+ if (nameEl) nameEl.textContent = filepath.split('/').pop();
2206
+ if (lineEl) lineEl.textContent = filepath;
2207
+ codeEl.innerHTML = '<span style="color:var(--text-dim)">Loading...</span>';
2208
+
2209
+ let source = null;
2210
+ const root = state._sourcesRoot || '';
2211
+ const fullPath = root ? `${root}/${filepath}` : filepath;
2212
+
2213
+ // Strategy 1: Read from disk via IPC (most reliable)
2214
+ if (window.electronAPI?.readSourceFile) {
2215
+ source = await window.electronAPI.readSourceFile(fullPath);
2216
+ }
2217
+
2218
+ // Strategy 2: Fetch from Metro
2219
+ if (!source) {
2220
+ try {
2221
+ const resp = await fetch(`http://localhost:8081/${filepath}?platform=ios&dev=true`);
2222
+ if (resp.ok) source = await resp.text();
2223
+ } catch {}
2224
+ }
2225
+
2226
+ if (!source) {
2227
+ codeEl.innerHTML = `<span style="color:var(--text-dim);padding:20px;display:block">Could not load: ${esc(filepath)}</span>`;
2228
+ return;
2229
+ }
2230
+
2231
+ // Render with line numbers
2232
+ const lines = source.split('\n');
2233
+ if (lineEl) lineEl.textContent = `${filepath} (${lines.length} lines)`;
2234
+ codeEl.innerHTML = '';
2235
+ const pre = document.createElement('pre');
2236
+ pre.className = 'source-pre';
2237
+ lines.forEach((line, i) => {
2238
+ const lineDiv = document.createElement('div');
2239
+ lineDiv.className = 'source-line';
2240
+ lineDiv.innerHTML = `<span class="source-line-num">${i + 1}</span><span class="source-line-code">${syntaxHighlight(esc(line))}</span>`;
2241
+ pre.appendChild(lineDiv);
2242
+ });
2243
+ codeEl.appendChild(pre);
2244
+ }
2245
+
2246
+ // Called from cdp-targets IPC handler (no longer opens external window)
2247
+
2248
+ // Called from cdp-targets IPC handler (shared, no duplicate registration)
2249
+ // Sources panel uses Metro source map for file tree — CDP targets are only
2250
+ // used for the "Breakpoints" button, not for the file list.
2251
+ function updateSourcesPanel(targets) {
2252
+ // No-op: file list is populated by fetchSourceFileList from Metro source map
2253
+ }
2254
+
2255
+ // ─────────────────────────────────────────────────────────────────────────────
2256
+ // PERFORMANCE PANEL — FPS, render timing, JS thread
2257
+ // ─────────────────────────────────────────────────────────────────────────────
2258
+ const perfState = { fps: [], jsThread: [], uiThread: [], recording: false, data: [] };
2259
+
2260
+ function initPerformancePanel() {
2261
+ const panel = $('panel-performance');
2262
+ panel.innerHTML = `
2263
+ <div class="panel-toolbar">
2264
+ <span class="panel-label">Performance</span>
2265
+ <div class="ml-auto" style="display:flex;gap:6px">
2266
+ <button class="tb-btn" id="btnPerfRecord">Record</button>
2267
+ <button class="tb-btn" id="btnPerfClear">Clear</button>
2268
+ </div>
2269
+ </div>
2270
+ <div class="perf-layout">
2271
+ <div class="perf-meters">
2272
+ <div class="perf-meter">
2273
+ <div class="perf-meter-label">FPS</div>
2274
+ <div class="perf-meter-value" id="perfFPS">—</div>
2275
+ <canvas class="perf-canvas" id="perfFPSCanvas" width="200" height="60"></canvas>
2276
+ </div>
2277
+ <div class="perf-meter">
2278
+ <div class="perf-meter-label">JS Thread</div>
2279
+ <div class="perf-meter-value" id="perfJS">—</div>
2280
+ <canvas class="perf-canvas" id="perfJSCanvas" width="200" height="60"></canvas>
2281
+ </div>
2282
+ <div class="perf-meter">
2283
+ <div class="perf-meter-label">UI Thread</div>
2284
+ <div class="perf-meter-value" id="perfUI">—</div>
2285
+ <canvas class="perf-canvas" id="perfUICanvas" width="200" height="60"></canvas>
2286
+ </div>
2287
+ </div>
2288
+ <div class="scroll-area perf-timeline" id="perfTimeline">
2289
+ <div class="empty-state" id="perfEmpty">
2290
+ <div class="icon" style="font-size:28px;opacity:.2">📊</div>
2291
+ <div class="label">No performance data</div>
2292
+ <div class="hint">Click "Record" to start capturing performance metrics</div>
2293
+ <div class="hint">The SDK sends FPS + thread usage automatically when connected</div>
2294
+ </div>
2295
+ </div>
2296
+ </div>`;
2297
+
2298
+ $('btnPerfRecord').addEventListener('click', () => {
2299
+ perfState.recording = !perfState.recording;
2300
+ $('btnPerfRecord').textContent = perfState.recording ? 'Stop' : 'Record';
2301
+ $('btnPerfRecord').classList.toggle('primary', perfState.recording);
2302
+ if (perfState.recording) {
2303
+ // Tell SDK to start sending perf data
2304
+ window.electronAPI?.setNetworkCapture(true); // reuse channel
2305
+ }
2306
+ });
2307
+
2308
+ $('btnPerfClear').addEventListener('click', () => {
2309
+ perfState.fps = [];
2310
+ perfState.jsThread = [];
2311
+ perfState.uiThread = [];
2312
+ perfState.data = [];
2313
+ $('perfFPS').textContent = '—';
2314
+ $('perfJS').textContent = '—';
2315
+ $('perfUI').textContent = '—';
2316
+ clearPerfCanvas('perfFPSCanvas');
2317
+ clearPerfCanvas('perfJSCanvas');
2318
+ clearPerfCanvas('perfUICanvas');
2319
+ });
2320
+ }
2321
+
2322
+ function clearPerfCanvas(id) {
2323
+ const canvas = $(id);
2324
+ if (!canvas) return;
2325
+ const ctx = canvas.getContext('2d');
2326
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2327
+ }
2328
+
2329
+ function drawPerfGraph(canvasId, data, maxVal, color) {
2330
+ const canvas = $(canvasId);
2331
+ if (!canvas || !data.length) return;
2332
+ const ctx = canvas.getContext('2d');
2333
+ const w = canvas.width, h = canvas.height;
2334
+ ctx.clearRect(0, 0, w, h);
2335
+
2336
+ // Grid lines
2337
+ ctx.strokeStyle = 'rgba(255,255,255,0.05)';
2338
+ ctx.lineWidth = 1;
2339
+ for (let y = 0; y < h; y += h/4) {
2340
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
2341
+ }
2342
+
2343
+ // Data line
2344
+ ctx.strokeStyle = color;
2345
+ ctx.lineWidth = 1.5;
2346
+ ctx.beginPath();
2347
+ const step = w / Math.max(data.length - 1, 1);
2348
+ data.forEach((v, i) => {
2349
+ const x = i * step;
2350
+ const y = h - (v / maxVal) * h;
2351
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
2352
+ });
2353
+ ctx.stroke();
2354
+
2355
+ // Fill under
2356
+ ctx.lineTo(w, h);
2357
+ ctx.lineTo(0, h);
2358
+ ctx.closePath();
2359
+ ctx.fillStyle = color.replace('1)', '0.1)');
2360
+ ctx.fill();
2361
+ }
2362
+
2363
+ // Handle performance events from SDK (always updates meters, graphs only when recording)
2364
+ function handlePerfEvent(event) {
2365
+ if (event.fps != null) {
2366
+ perfState.fps.push(event.fps);
2367
+ if (perfState.fps.length > 100) perfState.fps.shift();
2368
+ const fpsEl = $('perfFPS');
2369
+ if (fpsEl) fpsEl.textContent = event.fps + ' fps';
2370
+ drawPerfGraph('perfFPSCanvas', perfState.fps, 60, 'rgba(61,214,140,1)');
2371
+ }
2372
+ if (event.jsThread != null) {
2373
+ perfState.jsThread.push(event.jsThread);
2374
+ if (perfState.jsThread.length > 100) perfState.jsThread.shift();
2375
+ const jsEl = $('perfJS');
2376
+ if (jsEl) jsEl.textContent = event.jsThread.toFixed(1) + 'ms';
2377
+ drawPerfGraph('perfJSCanvas', perfState.jsThread, 32, 'rgba(79,172,255,1)');
2378
+ }
2379
+ if (event.uiThread != null) {
2380
+ perfState.uiThread.push(event.uiThread);
2381
+ if (perfState.uiThread.length > 100) perfState.uiThread.shift();
2382
+ const uiEl = $('perfUI');
2383
+ if (uiEl) uiEl.textContent = event.uiThread.toFixed(1) + 'ms';
2384
+ drawPerfGraph('perfUICanvas', perfState.uiThread, 32, 'rgba(155,127,255,1)');
2385
+ }
2386
+ }
2387
+
2388
+ // ─────────────────────────────────────────────────────────────────────────────
2389
+ // MEMORY PANEL — Heap snapshot summary via Hermes CDP
2390
+ // ─────────────────────────────────────────────────────────────────────────────
2391
+ function initMemoryPanel() {
2392
+ const panel = $('panel-memory');
2393
+ panel.innerHTML = `
2394
+ <div class="panel-toolbar">
2395
+ <span class="panel-label">Memory</span>
2396
+ <div class="ml-auto" style="display:flex;gap:6px">
2397
+ <button class="tb-btn primary" id="btnHeapSnapshot">Take Heap Snapshot</button>
2398
+ </div>
2399
+ </div>
2400
+ <div class="memory-layout">
2401
+ <div class="perf-meters" style="padding:14px">
2402
+ <div class="perf-meter">
2403
+ <div class="perf-meter-label">JS Heap Used</div>
2404
+ <div class="perf-meter-value" id="memHeapUsed">—</div>
2405
+ </div>
2406
+ <div class="perf-meter">
2407
+ <div class="perf-meter-label">JS Heap Total</div>
2408
+ <div class="perf-meter-value" id="memHeapTotal">—</div>
2409
+ </div>
2410
+ <div class="perf-meter">
2411
+ <div class="perf-meter-label">Native Memory</div>
2412
+ <div class="perf-meter-value" id="memNative">—</div>
2413
+ </div>
2414
+ </div>
2415
+ <div class="scroll-area" id="memoryContent">
2416
+ <div class="empty-state" id="memoryEmpty">
2417
+ <div class="icon" style="font-size:28px;opacity:.2">🧠</div>
2418
+ <div class="label">No memory data</div>
2419
+ <div class="hint">Click "Take Heap Snapshot" to capture memory usage</div>
2420
+ <div class="hint">Requires Hermes CDP connection (press Cmd+D first)</div>
2421
+ </div>
2422
+ </div>
2423
+ </div>`;
2424
+
2425
+ $('btnHeapSnapshot').addEventListener('click', () => {
2426
+ // Request heap snapshot via CDP - this opens the DevTools window
2427
+ // which has built-in Memory profiler
2428
+ window.electronAPI?.openCDPTarget(null);
2429
+ });
2430
+ }
2431
+
2432
+ // Handle memory events from SDK
2433
+ function handleMemoryEvent(event) {
2434
+ const hu = $('memHeapUsed'), ht = $('memHeapTotal'), mn = $('memNative');
2435
+ if (event.heapUsed != null && hu) hu.textContent = formatSize(event.heapUsed);
2436
+ if (event.heapTotal != null && ht) ht.textContent = formatSize(event.heapTotal);
2437
+ if (event.native != null && mn) mn.textContent = formatSize(event.native);
2438
+ }
2439
+
2440
+ // ─────────────────────────────────────────────────────────────────────────────
2441
+ // INIT
2442
+ // ─────────────────────────────────────────────────────────────────────────────
2443
+ initConsolePanel();
2444
+ initNetworkPanel();
2445
+ initPerformancePanel();
2446
+ initMemoryPanel();
2447
+ initReduxPanel();
2448
+ initStoragePanel();
2449
+ initReactPanel();
2450
+ initSettingsPanel();