reactoradar 1.6.11 → 1.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -71,10 +71,25 @@ function syntaxHighlight(json) {
71
71
  });
72
72
  }
73
73
 
74
+ // Sort object keys alphabetically for display (recursive)
75
+ function _sortKeys(obj) {
76
+ if (Array.isArray(obj)) return obj.map(_sortKeys);
77
+ if (obj !== null && typeof obj === 'object') {
78
+ const sorted = {};
79
+ Object.keys(obj).sort().forEach(k => { sorted[k] = _sortKeys(obj[k]); });
80
+ return sorted;
81
+ }
82
+ return obj;
83
+ }
84
+
74
85
  function renderJSON(val) {
75
86
  if (val == null) return '<span style="color:var(--text-dim)">Empty response</span>';
76
87
  try {
77
- const str = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
88
+ let data = val;
89
+ // Parse string JSON so we can sort keys
90
+ if (typeof data === 'string') { try { data = JSON.parse(data); } catch { return syntaxHighlight(esc(data)); } }
91
+ const sorted = _sortKeys(data);
92
+ const str = JSON.stringify(sorted, null, 2);
78
93
  if (!str || str === '{}' || str === '""') return '<span style="color:var(--text-dim)">Empty response body</span>';
79
94
  return syntaxHighlight(esc(str));
80
95
  } catch { try { return esc(JSON.stringify(val)); } catch { return esc('[Unserializable data]'); } }
package/main.js CHANGED
@@ -739,11 +739,15 @@ function setupIPC() {
739
739
  if (platform === 'android') {
740
740
  // adb logcat — show only new logs from now (not historical buffer)
741
741
  cmd = 'adb';
742
- args = ['logcat', '-v', 'threadtime', '-T', '1', '*:W']; // -T 1 = last 1 line then real-time
742
+ // -T 1 = last 1 line then real-time. Include Firebase tags at Verbose level for GA4 event capture.
743
+ args = ['logcat', '-v', 'threadtime', '-T', '1', 'FA:V', 'FA-SVC:V', 'FirebaseAnalytics:V', '*:W'];
743
744
  } else if (platform === 'ios-sim') {
744
- // xcrun simctl for iOS Simulator use syslog style for parseable output
745
- cmd = 'xcrun';
746
- args = ['simctl', 'spawn', 'booted', 'log', 'stream', '--style', 'syslog', '--level', 'error'];
745
+ // Use macOS unified log to capture iOS simulator logs
746
+ // processImagePath CONTAINS "CoreSimulator" filters to simulator processes only
747
+ // Captures errors + Firebase/FIRAnalytics events (requires -FIRDebugEnabled in Xcode scheme)
748
+ cmd = '/usr/bin/log';
749
+ args = ['stream', '--style', 'syslog', '--predicate',
750
+ 'processImagePath CONTAINS "CoreSimulator" AND (messageType >= error OR composedMessage CONTAINS "firebase" OR composedMessage CONTAINS "FIRAnalytics" OR composedMessage CONTAINS "Logging event" OR composedMessage CONTAINS "GoogleAnalytics" OR composedMessage CONTAINS "[GA4]")'];
747
751
  } else if (platform === 'ios-device') {
748
752
  // idevicesyslog for real iOS device
749
753
  cmd = 'idevicesyslog';
@@ -808,13 +812,50 @@ function setupIPC() {
808
812
  if (_nativeLogProcess) { try { _nativeLogProcess.kill(); } catch {} }
809
813
  });
810
814
 
815
+ // Firebase/GA tag detection for both Android and iOS
816
+ const _firebaseTags = new Set(['FA', 'FA-SVC', 'FA:Application', 'FA:Service', 'FirebaseAnalytics', 'FIRAnalytics', 'firebase', 'google.analytics', 'AnalyticsService']);
817
+ const _firebaseTagRe = /^(FA|FA-SVC|FA:Application|FA:Service|FirebaseAnalytics|FIRAnalytics|firebase|google\.analytics|AnalyticsService)/i;
818
+
819
+ function _parseFirebaseEvent(message, tag) {
820
+ if (!message) return null;
821
+ // Android FA tag patterns:
822
+ // "Logging event (FE): session_start(_s), Bundle[{...}]"
823
+ // "Setting event parameter: engagement_time_msec = 1234"
824
+ // "Screen exposed: main_screen"
825
+ let m;
826
+ if ((m = message.match(/Logging event\s*\(?(\w+)?\)?\s*:\s*(\S+?)(?:\((\w+)\))?\s*,?\s*Bundle\[\{(.*)\}\]/i))) {
827
+ const params = {};
828
+ // Parse "key=value, key=value" from Bundle
829
+ if (m[4]) m[4].split(/,\s*/).forEach(p => { const [k, v] = p.split('='); if (k) params[k.trim()] = v ? v.trim() : ''; });
830
+ return { eventName: m[2] || m[3] || 'unknown', source: m[1] || 'native', params };
831
+ }
832
+ if ((m = message.match(/Logging event\s*\(?(\w+)?\)?\s*:\s*(\S+)/i))) {
833
+ return { eventName: m[2], source: m[1] || 'native', params: {} };
834
+ }
835
+ // iOS FIRAnalytics pattern: "Logging event: origin, name, params: { ... }"
836
+ if ((m = message.match(/Logging event:\s*(\w+),\s*(\w+),\s*params:\s*(\{.*\})/i))) {
837
+ let params = {};
838
+ try { params = JSON.parse(m[3]); } catch {}
839
+ return { eventName: m[2], source: m[1] || 'native', params };
840
+ }
841
+ return null;
842
+ }
843
+
811
844
  function _parseNativeLog(line, platform) {
812
845
  if (platform === 'android') {
813
846
  // Android logcat format: "06-05 10:30:45.123 1234 5678 E TAG: message"
814
847
  const m = line.match(/^\d{2}-\d{2}\s+(\d{2}:\d{2}:\d{2})\.\d+\s+\d+\s+\d+\s+([VDIWEF])\s+([^:]+):\s*(.*)/);
815
848
  if (m) {
816
849
  const levelMap = { V: 'verbose', D: 'debug', I: 'info', W: 'warn', E: 'error', F: 'fatal' };
817
- return { ts: Date.now(), time: m[1], level: levelMap[m[2]] || 'info', tag: m[3].trim(), message: m[4], raw: line };
850
+ const tag = m[3].trim();
851
+ const parsed = { ts: Date.now(), time: m[1], level: levelMap[m[2]] || 'info', tag, message: m[4], raw: line };
852
+ // Detect Firebase/GA events
853
+ if (_firebaseTagRe.test(tag)) {
854
+ parsed.firebase = true;
855
+ const fe = _parseFirebaseEvent(m[4], tag);
856
+ if (fe) parsed.firebaseEvent = fe;
857
+ }
858
+ return parsed;
818
859
  }
819
860
  return { ts: Date.now(), level: 'info', message: line, raw: line };
820
861
  }
@@ -823,13 +864,25 @@ function setupIPC() {
823
864
  const m1 = line.match(/(\d{2}:\d{2}:\d{2})\.\d+[^\s]*\s+\S+\s+(\S+)\[\d+\].*?<(\w+)>:\s*(.*)/);
824
865
  if (m1) {
825
866
  const levelMap = { Notice: 'info', Info: 'info', Default: 'info', Debug: 'debug', Error: 'error', Fault: 'fatal' };
826
- return { ts: Date.now(), time: m1[1], level: levelMap[m1[3]] || 'info', tag: m1[2], message: m1[4], raw: line };
867
+ const parsed = { ts: Date.now(), time: m1[1], level: levelMap[m1[3]] || 'info', tag: m1[2], message: m1[4], raw: line };
868
+ if (_firebaseTagRe.test(m1[2]) || /FIRAnalytics|GoogleAnalytics|firebase/i.test(m1[4])) {
869
+ parsed.firebase = true;
870
+ const fe = _parseFirebaseEvent(m1[4], m1[2]);
871
+ if (fe) parsed.firebaseEvent = fe;
872
+ }
873
+ return parsed;
827
874
  }
828
875
  // idevicesyslog format: "Jun 5 10:30:45 iPhone MyApp(libsystem)[123] <Error>: message"
829
876
  const m2 = line.match(/\w+\s+\d+\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+(\S+?)[\[(].*?<(\w+)>:\s*(.*)/);
830
877
  if (m2) {
831
878
  const levelMap = { Notice: 'info', Info: 'info', Debug: 'debug', Warning: 'warn', Error: 'error', Critical: 'fatal' };
832
- return { ts: Date.now(), time: m2[1], level: levelMap[m2[3]] || 'info', tag: m2[2], message: m2[4], raw: line };
879
+ const parsed = { ts: Date.now(), time: m2[1], level: levelMap[m2[3]] || 'info', tag: m2[2], message: m2[4], raw: line };
880
+ if (_firebaseTagRe.test(m2[2]) || /FIRAnalytics|GoogleAnalytics|firebase/i.test(m2[4])) {
881
+ parsed.firebase = true;
882
+ const fe = _parseFirebaseEvent(m2[4], m2[2]);
883
+ if (fe) parsed.firebaseEvent = fe;
884
+ }
885
+ return parsed;
833
886
  }
834
887
  // Fallback
835
888
  const timeMatch = line.match(/(\d{2}:\d{2}:\d{2})/);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reactoradar",
3
3
  "productName": "ReactoRadar",
4
- "version": "1.6.11",
4
+ "version": "1.6.12",
5
5
  "description": "macOS debugger for React Native — Console, Sources, Network, Performance, Memory, Redux, AsyncStorage, React tree. Supports RN 0.74+ with Hermes and New Architecture.",
6
6
  "main": "main.js",
7
7
  "bin": {
package/panels/console.js CHANGED
@@ -359,7 +359,8 @@ function collectEntries(val) {
359
359
  if (!(k in result)) result[k] = val[k];
360
360
  }
361
361
 
362
- return Object.entries(result);
362
+ // Sort keys alphabetically for consistent, readable display
363
+ return Object.entries(result).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
363
364
  }
364
365
 
365
366
  function objPreview(val, maxLen) {
@@ -400,14 +401,23 @@ function primitivePreview(val) {
400
401
  if (typeof val === 'string') return val.length > 50 ? `"${val.slice(0,50)}..."` : `"${val}"`;
401
402
  if (typeof val === 'number' || typeof val === 'boolean') return String(val);
402
403
  if (Array.isArray(val)) return `Array(${val.length})`;
403
- if (typeof val === 'object') return `{...}`;
404
+ if (typeof val === 'object') {
405
+ const keys = Object.keys(val);
406
+ if (keys.length === 0) return '{}';
407
+ if (keys.length <= 3) return `{${keys.join(', ')}}`;
408
+ return `{${keys.slice(0, 3).join(', ')}, +${keys.length - 3}}`;
409
+ }
404
410
  if (typeof val === 'function') return `[Function: ${val.name || 'anonymous'}]`;
405
411
  return safeStr(val);
406
412
  }
407
413
 
408
- function createTreeNode(key, val, startCollapsed) {
414
+ function createTreeNode(key, val, startCollapsed, parentPath) {
409
415
  const isArray = Array.isArray(val);
410
416
  const isObj = val !== null && typeof val === 'object';
417
+ // Build full dot-notation path for "Copy path"
418
+ const fullPath = key !== null
419
+ ? (parentPath ? (typeof key === 'number' ? `${parentPath}[${key}]` : `${parentPath}.${key}`) : String(key))
420
+ : (parentPath || '');
411
421
 
412
422
  if (!isObj) {
413
423
  // Primitive leaf
@@ -420,6 +430,16 @@ function createTreeNode(key, val, startCollapsed) {
420
430
  row.appendChild(k);
421
431
  }
422
432
  row.appendChild(createPrimitiveSpan(val));
433
+ // Right-click to copy value
434
+ row.addEventListener('contextmenu', (e) => {
435
+ e.preventDefault();
436
+ e.stopPropagation();
437
+ const items = [];
438
+ if (key !== null) items.push({ label: `Copy value of "${key}"`, action: () => navigator.clipboard.writeText(safeStr(val)) });
439
+ items.push({ label: 'Copy as JSON', action: () => { try { navigator.clipboard.writeText(JSON.stringify(val)); } catch { navigator.clipboard.writeText(safeStr(val)); } } });
440
+ if (fullPath) items.push({ label: 'Copy path', action: () => navigator.clipboard.writeText(fullPath) });
441
+ showContextMenu(e, items);
442
+ });
423
443
  return row;
424
444
  }
425
445
 
@@ -460,7 +480,7 @@ function createTreeNode(key, val, startCollapsed) {
460
480
  populated = true;
461
481
  const entries = collectEntries(val);
462
482
  entries.forEach(([k, v]) => {
463
- children.appendChild(createTreeNode(k, v, true));
483
+ children.appendChild(createTreeNode(k, v, true, fullPath));
464
484
  });
465
485
  // For arrays show length, for objects show prototype hint
466
486
  if (isArray) {
@@ -497,6 +517,26 @@ function createTreeNode(key, val, startCollapsed) {
497
517
  }
498
518
  });
499
519
 
520
+ // Right-click on any node to copy its subtree
521
+ header.addEventListener('contextmenu', (e) => {
522
+ e.preventDefault();
523
+ e.stopPropagation();
524
+ const items = [];
525
+ if (key !== null) {
526
+ items.push({ label: `Copy "${key}" value`, action: () => {
527
+ try { navigator.clipboard.writeText(JSON.stringify(val, null, 2)); } catch { navigator.clipboard.writeText(safeStr(val)); }
528
+ }});
529
+ items.push({ label: `Copy "${key}" key-value`, action: () => {
530
+ try { navigator.clipboard.writeText(JSON.stringify({ [key]: val }, null, 2)); } catch { navigator.clipboard.writeText(`${key}: ${safeStr(val)}`); }
531
+ }});
532
+ }
533
+ items.push({ label: 'Copy entire object', action: () => {
534
+ try { navigator.clipboard.writeText(JSON.stringify(val, null, 2)); } catch { navigator.clipboard.writeText(safeStr(val)); }
535
+ }});
536
+ if (fullPath) items.push({ label: 'Copy path', action: () => navigator.clipboard.writeText(fullPath) });
537
+ showContextMenu(e, items);
538
+ });
539
+
500
540
  container.appendChild(children);
501
541
  return container;
502
542
  }
package/panels/native.js CHANGED
@@ -36,6 +36,8 @@ function initNativeLogsPanel() {
36
36
  <div class="native-prereq-step">2. Enable <b>USB Debugging</b><br/><span style="color:var(--text-dim);font-size:9px">Settings → Developer Options → USB Debugging → ON</span></div>
37
37
  <div class="native-prereq-step">3. Connect device via USB and accept the prompt</div>
38
38
  <div class="native-prereq-step">4. Verify: run <code>adb devices</code> in terminal</div>
39
+ <div class="native-prereq-step" style="margin-top:6px"><b>Firebase events:</b></div>
40
+ <div class="native-prereq-step"><code>adb shell setprop debug.firebase.analytics.app &lt;pkg&gt;</code></div>
39
41
  </div>
40
42
  <div id="nativeAndroidStatus" class="native-detect-status"></div>
41
43
  <button class="native-connect-btn" id="nativeConnectAndroid">Connect Android</button>
@@ -51,6 +53,8 @@ function initNativeLogsPanel() {
51
53
  <div class="native-prereq-step">1. Install: <code>brew install libimobiledevice</code></div>
52
54
  <div class="native-prereq-step">2. Connect device, tap <b>Trust</b> on the prompt</div>
53
55
  <div class="native-prereq-step">3. Verify: <code>idevice_id -l</code> shows device UDID</div>
56
+ <div class="native-prereq-step" style="margin-top:6px"><b>Firebase events:</b></div>
57
+ <div class="native-prereq-step">Add <code>-FIRDebugEnabled</code> to Xcode scheme launch arguments</div>
54
58
  </div>
55
59
  <div id="nativeIOSStatus" class="native-detect-status"></div>
56
60
  <div style="display:flex;gap:6px;margin-top:8px">
@@ -66,6 +70,7 @@ function initNativeLogsPanel() {
66
70
  <input id="nativeSearch" class="net-search-input" placeholder="Filter logs..." />
67
71
  <div class="native-level-filters" id="nativeLevelFilters">
68
72
  <button class="net-status-btn active" data-level="all">All</button>
73
+ <button class="net-status-btn" data-level="firebase" style="color:var(--yellow)">🔥 Firebase</button>
69
74
  <button class="net-status-btn" data-level="fatal">Fatal</button>
70
75
  <button class="net-status-btn" data-level="error">Error</button>
71
76
  <button class="net-status-btn" data-level="warn">Warn</button>
@@ -197,51 +202,103 @@ function _appendNativeLog(log) {
197
202
  if (!list) return;
198
203
 
199
204
  // Check filters
200
- if (_nativeState.levelFilter !== 'all' && log.level !== _nativeState.levelFilter) return;
205
+ if (_nativeState.levelFilter === 'firebase') {
206
+ if (!log.firebase) return; // Only show Firebase-tagged logs
207
+ } else if (_nativeState.levelFilter !== 'all' && log.level !== _nativeState.levelFilter) {
208
+ return;
209
+ }
201
210
  if (_nativeState.searchFilter && !log.message?.toLowerCase().includes(_nativeState.searchFilter) && !log.tag?.toLowerCase().includes(_nativeState.searchFilter)) return;
202
211
 
203
- const isExpandable = log.level === 'error' || log.level === 'fatal' || (log.message || '').length > 200;
204
- const row = document.createElement('div');
205
- row.className = `native-log-row native-${log.level || 'info'}`;
206
-
207
212
  const time = log.time || new Date(log.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
208
213
 
209
- // Header line (always visible)
210
- const header = document.createElement('div');
211
- header.className = 'native-log-header';
212
- header.innerHTML = `<span class="native-log-time">${esc(time)}</span>`
213
- + `<span class="native-log-level">${esc((log.level || 'info').toUpperCase())}</span>`
214
- + (log.tag ? `<span class="native-log-tag">${esc(log.tag)}</span>` : '')
215
- + `<span class="native-log-preview">${esc((log.message || '').split('\\n')[0].slice(0, 200))}</span>`;
216
- row.appendChild(header);
214
+ // Firebase events render GA4-style cards
215
+ if (log.firebase && log.firebaseEvent && _nativeState.levelFilter === 'firebase') {
216
+ const fe = log.firebaseEvent;
217
+ const row = document.createElement('div');
218
+ row.className = 'native-firebase-card';
219
+
220
+ const paramKeys = Object.keys(fe.params || {});
221
+ const paramHtml = paramKeys.length > 0
222
+ ? `<div class="native-firebase-params">${paramKeys.map(k =>
223
+ `<div class="native-firebase-param"><span class="native-firebase-param-key">${esc(k)}</span><span class="native-firebase-param-val">${esc(safeStr(fe.params[k]))}</span></div>`
224
+ ).join('')}</div>`
225
+ : '';
226
+
227
+ row.innerHTML = `
228
+ <div class="native-firebase-header">
229
+ <span class="native-firebase-event">${esc(fe.eventName)}</span>
230
+ <span class="native-firebase-source">${esc(fe.source)}</span>
231
+ <span class="native-firebase-time">${esc(time)}</span>
232
+ </div>
233
+ ${paramHtml}`;
217
234
 
218
- // Expandable full message (for errors and long messages)
219
- if (isExpandable) {
220
- const fullMsg = document.createElement('div');
221
- fullMsg.className = 'native-log-full';
222
- fullMsg.style.display = 'none';
223
- fullMsg.textContent = log.message || '';
224
- row.appendChild(fullMsg);
235
+ // Expand/collapse params
236
+ const header = row.querySelector('.native-firebase-header');
237
+ const params = row.querySelector('.native-firebase-params');
238
+ if (header && params) {
239
+ params.style.display = 'none';
240
+ header.style.cursor = 'pointer';
241
+ header.addEventListener('click', () => {
242
+ const open = params.style.display !== 'none';
243
+ params.style.display = open ? 'none' : 'block';
244
+ row.classList.toggle('expanded', !open);
245
+ });
246
+ }
225
247
 
226
- header.style.cursor = 'pointer';
227
- header.addEventListener('click', () => {
228
- const open = fullMsg.style.display !== 'none';
229
- fullMsg.style.display = open ? 'none' : 'block';
230
- row.classList.toggle('expanded', !open);
248
+ // Right-click
249
+ row.addEventListener('contextmenu', (e) => {
250
+ e.preventDefault();
251
+ showContextMenu(e, [
252
+ { label: `Copy Event: ${fe.eventName}`, action: () => navigator.clipboard.writeText(fe.eventName) },
253
+ { label: 'Copy Params (JSON)', action: () => navigator.clipboard.writeText(JSON.stringify(fe.params, null, 2)) },
254
+ { label: 'Copy Raw Log', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
255
+ ]);
231
256
  });
232
- }
233
257
 
234
- // Right-click to copy
235
- row.addEventListener('contextmenu', (e) => {
236
- e.preventDefault();
237
- showContextMenu(e, [
238
- { label: 'Copy Message', action: () => navigator.clipboard.writeText(log.message || '') },
239
- { label: 'Copy Raw Line', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
240
- ...(log.tag ? [{ label: `Copy Tag (${log.tag})`, action: () => navigator.clipboard.writeText(log.tag) }] : []),
241
- ]);
242
- });
258
+ list.appendChild(row);
259
+ } else {
260
+ // Standard log row — with Firebase badge if applicable
261
+ const isExpandable = log.level === 'error' || log.level === 'fatal' || (log.message || '').length > 200;
262
+ const row = document.createElement('div');
263
+ row.className = `native-log-row native-${log.level || 'info'}${log.firebase ? ' native-firebase' : ''}`;
243
264
 
244
- list.appendChild(row);
265
+ const header = document.createElement('div');
266
+ header.className = 'native-log-header';
267
+ header.innerHTML = `<span class="native-log-time">${esc(time)}</span>`
268
+ + `<span class="native-log-level">${esc((log.level || 'info').toUpperCase())}</span>`
269
+ + (log.firebase ? '<span class="native-firebase-badge">Firebase</span>' : '')
270
+ + (log.tag ? `<span class="native-log-tag">${esc(log.tag)}</span>` : '')
271
+ + `<span class="native-log-preview">${esc((log.message || '').split('\\n')[0].slice(0, 200))}</span>`;
272
+ row.appendChild(header);
273
+
274
+ if (isExpandable) {
275
+ const fullMsg = document.createElement('div');
276
+ fullMsg.className = 'native-log-full';
277
+ fullMsg.style.display = 'none';
278
+ fullMsg.textContent = log.message || '';
279
+ row.appendChild(fullMsg);
280
+
281
+ header.style.cursor = 'pointer';
282
+ header.addEventListener('click', () => {
283
+ const open = fullMsg.style.display !== 'none';
284
+ fullMsg.style.display = open ? 'none' : 'block';
285
+ row.classList.toggle('expanded', !open);
286
+ });
287
+ }
288
+
289
+ row.addEventListener('contextmenu', (e) => {
290
+ e.preventDefault();
291
+ const items = [
292
+ { label: 'Copy Message', action: () => navigator.clipboard.writeText(log.message || '') },
293
+ { label: 'Copy Raw Line', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
294
+ ];
295
+ if (log.tag) items.push({ label: `Copy Tag (${log.tag})`, action: () => navigator.clipboard.writeText(log.tag) });
296
+ if (log.firebaseEvent) items.push({ label: `Copy Event: ${log.firebaseEvent.eventName}`, action: () => navigator.clipboard.writeText(log.firebaseEvent.eventName) });
297
+ showContextMenu(e, items);
298
+ });
299
+
300
+ list.appendChild(row);
301
+ }
245
302
 
246
303
  // Cap DOM rows
247
304
  while (list.children.length > 1000) list.firstChild.remove();
package/panels/network.js CHANGED
@@ -918,6 +918,28 @@ function renderNetDetailContent(r) {
918
918
  } else {
919
919
  body.innerHTML = renderJSON(r.responseBody);
920
920
  }
921
+ // Right-click to copy response object
922
+ body.addEventListener('contextmenu', (e) => {
923
+ e.preventDefault();
924
+ const sel = window.getSelection();
925
+ const items = [];
926
+ if (sel && sel.toString().length > 0) {
927
+ items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
928
+ }
929
+ items.push({ label: 'Copy Response Object', action: () => {
930
+ try {
931
+ const data = typeof r.responseBody === 'string' ? JSON.parse(r.responseBody) : r.responseBody;
932
+ navigator.clipboard.writeText(JSON.stringify(_sortKeys(data), null, 2));
933
+ } catch { navigator.clipboard.writeText(safeStr(r.responseBody)); }
934
+ }});
935
+ items.push({ label: 'Copy Response (minified)', action: () => {
936
+ try {
937
+ const data = typeof r.responseBody === 'string' ? JSON.parse(r.responseBody) : r.responseBody;
938
+ navigator.clipboard.writeText(JSON.stringify(_sortKeys(data)));
939
+ } catch { navigator.clipboard.writeText(safeStr(r.responseBody)); }
940
+ }});
941
+ showContextMenu(e, items);
942
+ });
921
943
  }
922
944
  }
923
945
 
@@ -944,7 +966,7 @@ function showNetContextMenu(e, r) {
944
966
 
945
967
  function showPreviewCopyMenu(e, fullData) {
946
968
  const items = [
947
- { label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(fullData, null, 2)) },
969
+ { label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(_sortKeys(fullData), null, 2)) },
948
970
  ];
949
971
  const sel = window.getSelection();
950
972
  if (sel && sel.toString().length > 0) {
package/panels/redux.js CHANGED
@@ -82,7 +82,7 @@ function _findLeafChanges(oldVal, newVal, basePath, maxDepth) {
82
82
  changes.push({ path, oldVal: a, newVal: b });
83
83
  return;
84
84
  }
85
- const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
85
+ const allKeys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
86
86
  allKeys.forEach(k => {
87
87
  if (!_deepEqual(a[k], b[k])) {
88
88
  const childPath = path ? `${path}.${k}` : k;
@@ -169,7 +169,7 @@ function _createHighlightedTree(key, val, changedPaths, currentPath, isOld) {
169
169
  function populate() {
170
170
  if (populated) return;
171
171
  populated = true;
172
- const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val);
172
+ const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
173
173
  entries.forEach(([k, v]) => {
174
174
  children.appendChild(_createHighlightedTree(k, v, changedPaths, myPath, isOld));
175
175
  });
@@ -197,7 +197,7 @@ function handleReduxEvent(event) {
197
197
  const prevState = state.redux.states.length > 0 ? state.redux.states[state.redux.states.length - 1] : null;
198
198
  const changedKeys = [];
199
199
  if (prevState && nextState && typeof prevState === 'object' && typeof nextState === 'object') {
200
- const allKeys = new Set([...Object.keys(prevState), ...Object.keys(nextState)]);
200
+ const allKeys = [...new Set([...Object.keys(prevState), ...Object.keys(nextState)])].sort();
201
201
  allKeys.forEach(k => { if (!_deepEqual(prevState[k], nextState[k])) changedKeys.push(k); });
202
202
  }
203
203
 
package/styles.css CHANGED
@@ -1799,6 +1799,78 @@ mark { background: rgba(79,172,255,.2); color: var(--accent); border-radius: 2px
1799
1799
  .native-fatal { background: rgba(255,94,114,.08); }
1800
1800
  .native-fatal .native-log-msg { color: var(--red); font-weight: 700; }
1801
1801
 
1802
+ /* Firebase event cards (GA4-style in Native Logs) */
1803
+ .native-firebase-badge {
1804
+ display: inline-block;
1805
+ background: rgba(255,196,0,.15);
1806
+ color: #ffa500;
1807
+ font-size: 9px;
1808
+ font-weight: 700;
1809
+ padding: 1px 5px;
1810
+ border-radius: 3px;
1811
+ margin-right: 4px;
1812
+ text-transform: uppercase;
1813
+ }
1814
+ .native-firebase { border-left: 2px solid #ffa500; }
1815
+ .native-firebase-card {
1816
+ padding: 8px 12px;
1817
+ border-bottom: 1px solid var(--border);
1818
+ border-left: 3px solid #ffa500;
1819
+ transition: background 0.12s;
1820
+ }
1821
+ .native-firebase-card:hover { background: var(--bg3); }
1822
+ .native-firebase-card.expanded { background: var(--bg3); }
1823
+ .native-firebase-header {
1824
+ display: flex;
1825
+ align-items: center;
1826
+ gap: 8px;
1827
+ font-size: 11px;
1828
+ }
1829
+ .native-firebase-event {
1830
+ font-weight: 700;
1831
+ color: var(--accent);
1832
+ font-size: 12px;
1833
+ }
1834
+ .native-firebase-source {
1835
+ font-size: 9px;
1836
+ color: var(--text-dim);
1837
+ background: var(--bg3);
1838
+ padding: 1px 5px;
1839
+ border-radius: 3px;
1840
+ text-transform: uppercase;
1841
+ }
1842
+ .native-firebase-time {
1843
+ margin-left: auto;
1844
+ font-size: 10px;
1845
+ color: var(--text-dim);
1846
+ font-variant-numeric: tabular-nums;
1847
+ }
1848
+ .native-firebase-params {
1849
+ margin-top: 6px;
1850
+ padding: 6px 8px;
1851
+ background: var(--bg2);
1852
+ border-radius: 4px;
1853
+ border: 1px solid var(--border);
1854
+ }
1855
+ .native-firebase-param {
1856
+ display: flex;
1857
+ gap: 8px;
1858
+ padding: 2px 0;
1859
+ font-size: 10px;
1860
+ border-bottom: 1px solid var(--border);
1861
+ }
1862
+ .native-firebase-param:last-child { border-bottom: none; }
1863
+ .native-firebase-param-key {
1864
+ color: var(--accent);
1865
+ font-weight: 600;
1866
+ min-width: 120px;
1867
+ flex-shrink: 0;
1868
+ }
1869
+ .native-firebase-param-val {
1870
+ color: var(--text-mid);
1871
+ word-break: break-all;
1872
+ }
1873
+
1802
1874
  /* ── Detail Panel Search ───────────────────────────────────────────────────── */
1803
1875
  .detail-search-wrap { display: flex; align-items: center; gap: 4px; margin-left: auto; padding: 0 6px; }
1804
1876
  .detail-search-input { width: 150px; font-size: 10px; padding: 3px 6px; border: 1px solid var(--border); background: var(--bg2); color: var(--text); border-radius: 3px; outline: none; }